@cabane/companion 0.6.79 → 0.6.82
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 +356 -377
- package/dist/runtime.js +356 -377
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1735,7 +1735,7 @@ function getLogger() {
|
|
|
1735
1735
|
}
|
|
1736
1736
|
|
|
1737
1737
|
// src/runtime.ts
|
|
1738
|
-
import { randomUUID as
|
|
1738
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
1739
1739
|
|
|
1740
1740
|
// src/dashboard/server.ts
|
|
1741
1741
|
import { dirname as dirname4, join as join7 } from "path";
|
|
@@ -2279,7 +2279,7 @@ async function warnAboutHarnessReadiness(cfg, deps = {}) {
|
|
|
2279
2279
|
}
|
|
2280
2280
|
|
|
2281
2281
|
// src/supervisor.ts
|
|
2282
|
-
import { randomUUID as
|
|
2282
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
2283
2283
|
|
|
2284
2284
|
// src/api.ts
|
|
2285
2285
|
var RETRY_BACKOFF_MS = [250, 750];
|
|
@@ -2459,14 +2459,14 @@ var CabaneApi = class {
|
|
|
2459
2459
|
const q = `conversationId=${encodeURIComponent(conversationId)}&messageId=${encodeURIComponent(messageId2)}` + (turnId ? `&turnId=${encodeURIComponent(turnId)}` : "");
|
|
2460
2460
|
return this.request("GET", `/api/agent/turn-context?${q}`);
|
|
2461
2461
|
}
|
|
2462
|
-
// CT714: read a turn's recorded turn-control intent
|
|
2463
|
-
//
|
|
2464
|
-
//
|
|
2465
|
-
//
|
|
2466
|
-
//
|
|
2467
|
-
//
|
|
2468
|
-
//
|
|
2469
|
-
//
|
|
2462
|
+
// CT714: read a turn's recorded turn-control intent. An EXTERNAL adapter
|
|
2463
|
+
// (Codex / opencode) records `reply_to` / `skip_turn` into `turn_intents`
|
|
2464
|
+
// server-side (the URL MCP surface) rather than the dispatcher's in-memory
|
|
2465
|
+
// closures, so the dispatcher fetches this once at settle — by `turnId` —
|
|
2466
|
+
// and populates those closures, letting the unchanged settle path close the
|
|
2467
|
+
// turn identically to claude-code. CT1354: those two verbs are all it
|
|
2468
|
+
// carries now; the outgoing acts commit at the call (`turnAct`). Agent-PAT
|
|
2469
|
+
// authed + self-scoped (`:agentId` must match the PAT's agent).
|
|
2470
2470
|
getTurnIntent(workspaceId, conversationId, agentId, turnId) {
|
|
2471
2471
|
const q = `turnId=${encodeURIComponent(turnId)}`;
|
|
2472
2472
|
return this.request(
|
|
@@ -2474,6 +2474,27 @@ var CabaneApi = class {
|
|
|
2474
2474
|
`/api/workspaces/${workspaceId}/conversations/${conversationId}/participants/agents/${agentId}/turn-intent?${q}`
|
|
2475
2475
|
);
|
|
2476
2476
|
}
|
|
2477
|
+
// CT1354: COMMIT ONE OUTGOING ACT of a running turn — a `send`, an `ask`, a
|
|
2478
|
+
// `wake_me`, a `cancel_wake` — the moment the tool is called. The server
|
|
2479
|
+
// runs `performTurnAct` inside the turn's write fence: the row is in the
|
|
2480
|
+
// ledger when this resolves, and a 200 `ok: false` is the server's REFUSAL
|
|
2481
|
+
// (an unknown target, a non-member, a wake outside its guardrails) for the
|
|
2482
|
+
// tool to hand back as its error. 409 means the turn already settled.
|
|
2483
|
+
//
|
|
2484
|
+
// Bounded retry, on purpose: the act is a durable write whose loss strands a
|
|
2485
|
+
// person or a peer, and `act.callId` — minted once per tool invocation by the
|
|
2486
|
+
// caller — is what makes the retry safe: the server keys on `(turnId,
|
|
2487
|
+
// callId)`, so an attempt that landed before the response was lost is
|
|
2488
|
+
// replayed as the same row, never a second message. The caller's `signal`
|
|
2489
|
+
// is the per-call deadline (the tool must answer the model, not hang it).
|
|
2490
|
+
turnAct(workspaceId, turnId, act, signal) {
|
|
2491
|
+
return this.request(
|
|
2492
|
+
"POST",
|
|
2493
|
+
`/api/workspaces/${workspaceId}/turns/${turnId}/acts`,
|
|
2494
|
+
act,
|
|
2495
|
+
signal ? { retry: true, signal } : { retry: true }
|
|
2496
|
+
);
|
|
2497
|
+
}
|
|
2477
2498
|
// CT1292: RENEW THE TURN'S LEASE — ask the server whether this turn is still
|
|
2478
2499
|
// running. The dispatcher calls this on a cadence for the life of the SDK loop,
|
|
2479
2500
|
// and out of cadence the moment a commit is refused, so a loop whose turn was
|
|
@@ -5100,6 +5121,10 @@ async function* decodeOpencodeStream(events, ctx) {
|
|
|
5100
5121
|
if (sealed) yield sealed;
|
|
5101
5122
|
ok = false;
|
|
5102
5123
|
const errorText = readSessionError(ev.properties);
|
|
5124
|
+
const notice = errorText.trim().slice(0, 200).trimEnd();
|
|
5125
|
+
if (notice && !/^[\w:-]+$/.test(notice)) {
|
|
5126
|
+
yield { type: "runtime_notice", body: notice };
|
|
5127
|
+
}
|
|
5103
5128
|
const failure = classifyErrorText(errorText);
|
|
5104
5129
|
reason = failure ? encodeFailureReason(failure) : `error:${errorText.slice(0, 200)}`;
|
|
5105
5130
|
settled = true;
|
|
@@ -5509,6 +5534,7 @@ var opencodeAdapter = createOpencodeAdapter();
|
|
|
5509
5534
|
// packages/agent-runtime/src/opencode/conformance.ts
|
|
5510
5535
|
var ABORT_SENTINEL2 = { __abortHere: true };
|
|
5511
5536
|
var NEW_SESSION_ID = "sess_new";
|
|
5537
|
+
var BOUNDED_NOTICE = "You've hit your usage limit \xB7 ".padEnd(200, "x");
|
|
5512
5538
|
var COMPANION_POLICY = {
|
|
5513
5539
|
hostFs: false,
|
|
5514
5540
|
web: true,
|
|
@@ -5700,6 +5726,7 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
|
|
|
5700
5726
|
expected: [
|
|
5701
5727
|
sessionEvent2(NEW_SESSION_ID),
|
|
5702
5728
|
{ type: "text", body: "Partial work.", terminal: false },
|
|
5729
|
+
{ type: "runtime_notice", body: "provider exploded" },
|
|
5703
5730
|
{ type: "result", ok: false, reason: "error:provider exploded" }
|
|
5704
5731
|
]
|
|
5705
5732
|
},
|
|
@@ -5716,7 +5743,11 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
|
|
|
5716
5743
|
properties: { error: { name: "RateLimitError", message: "rate limit exceeded (429)" } }
|
|
5717
5744
|
}
|
|
5718
5745
|
],
|
|
5719
|
-
expected: [
|
|
5746
|
+
expected: [
|
|
5747
|
+
sessionEvent2(NEW_SESSION_ID),
|
|
5748
|
+
{ type: "runtime_notice", body: "RateLimitError: rate limit exceeded (429)" },
|
|
5749
|
+
{ type: "result", ok: false, reason: "rate_limited" }
|
|
5750
|
+
]
|
|
5720
5751
|
},
|
|
5721
5752
|
{
|
|
5722
5753
|
// CT592: a subscription cap surfaced as text ("usage limit reached") → the cap
|
|
@@ -5730,7 +5761,11 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
|
|
|
5730
5761
|
properties: { error: { name: "Error", message: "Claude usage limit reached" } }
|
|
5731
5762
|
}
|
|
5732
5763
|
],
|
|
5733
|
-
expected: [
|
|
5764
|
+
expected: [
|
|
5765
|
+
sessionEvent2(NEW_SESSION_ID),
|
|
5766
|
+
{ type: "runtime_notice", body: "Claude usage limit reached" },
|
|
5767
|
+
{ type: "result", ok: false, reason: "usage_capped" }
|
|
5768
|
+
]
|
|
5734
5769
|
},
|
|
5735
5770
|
{
|
|
5736
5771
|
// CT592: a transient provider outage (5xx / overload) surfaced as text → the
|
|
@@ -5746,7 +5781,11 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
|
|
|
5746
5781
|
}
|
|
5747
5782
|
}
|
|
5748
5783
|
],
|
|
5749
|
-
expected: [
|
|
5784
|
+
expected: [
|
|
5785
|
+
sessionEvent2(NEW_SESSION_ID),
|
|
5786
|
+
{ type: "runtime_notice", body: "AI_APICallError: overloaded_error (529)" },
|
|
5787
|
+
{ type: "result", ok: false, reason: "server_error" }
|
|
5788
|
+
]
|
|
5750
5789
|
},
|
|
5751
5790
|
{
|
|
5752
5791
|
// CT558: an expired/invalid credential classifies as `auth_expired`.
|
|
@@ -5758,7 +5797,66 @@ var OPENCODE_CONFORMANCE_FIXTURES = [
|
|
|
5758
5797
|
properties: { error: { name: "AuthenticationError", message: "HTTP 401 Unauthorized" } }
|
|
5759
5798
|
}
|
|
5760
5799
|
],
|
|
5761
|
-
expected: [
|
|
5800
|
+
expected: [
|
|
5801
|
+
sessionEvent2(NEW_SESSION_ID),
|
|
5802
|
+
{ type: "runtime_notice", body: "AuthenticationError: HTTP 401 Unauthorized" },
|
|
5803
|
+
{ type: "result", ok: false, reason: "auth_expired" }
|
|
5804
|
+
]
|
|
5805
|
+
},
|
|
5806
|
+
{
|
|
5807
|
+
// CT1351: an unclassified failure remains as informative as a classified one:
|
|
5808
|
+
// its provider text is visible while the raw machine reason stays unchanged.
|
|
5809
|
+
name: "unclassified failure keeps a runtime notice and raw reason",
|
|
5810
|
+
request: makeRequest2(),
|
|
5811
|
+
nativeStream: [errored("something local broke")],
|
|
5812
|
+
expected: [
|
|
5813
|
+
sessionEvent2(NEW_SESSION_ID),
|
|
5814
|
+
{ type: "runtime_notice", body: "something local broke" },
|
|
5815
|
+
{ type: "result", ok: false, reason: "error:something local broke" }
|
|
5816
|
+
]
|
|
5817
|
+
},
|
|
5818
|
+
{
|
|
5819
|
+
name: "runtime notice is trimmed and bounded to 200 characters",
|
|
5820
|
+
request: makeRequest2(),
|
|
5821
|
+
nativeStream: [errored(` ${BOUNDED_NOTICE}${"y".repeat(20)} `)],
|
|
5822
|
+
expected: [
|
|
5823
|
+
sessionEvent2(NEW_SESSION_ID),
|
|
5824
|
+
{ type: "runtime_notice", body: BOUNDED_NOTICE },
|
|
5825
|
+
{ type: "result", ok: false, reason: "usage_capped" }
|
|
5826
|
+
]
|
|
5827
|
+
},
|
|
5828
|
+
{
|
|
5829
|
+
// CT1351: empty, whitespace-only, and bare status-token failures still set
|
|
5830
|
+
// the machine reason but do not add an empty/unhelpful transcript notice.
|
|
5831
|
+
name: "empty failure text emits no runtime notice",
|
|
5832
|
+
request: makeRequest2(),
|
|
5833
|
+
nativeStream: [errored("")],
|
|
5834
|
+
expected: [
|
|
5835
|
+
sessionEvent2(NEW_SESSION_ID),
|
|
5836
|
+
{ type: "result", ok: false, reason: "error:unknown" }
|
|
5837
|
+
]
|
|
5838
|
+
},
|
|
5839
|
+
{
|
|
5840
|
+
name: "whitespace-only failure text emits no runtime notice",
|
|
5841
|
+
request: makeRequest2(),
|
|
5842
|
+
nativeStream: [errored(" ")],
|
|
5843
|
+
expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: false, reason: "error: " }]
|
|
5844
|
+
},
|
|
5845
|
+
{
|
|
5846
|
+
name: "bare status token emits no runtime notice",
|
|
5847
|
+
request: makeRequest2(),
|
|
5848
|
+
nativeStream: [errored("429")],
|
|
5849
|
+
expected: [sessionEvent2(NEW_SESSION_ID), { type: "result", ok: false, reason: "rate_limited" }]
|
|
5850
|
+
},
|
|
5851
|
+
{
|
|
5852
|
+
name: "single-word provider sentence emits a runtime notice",
|
|
5853
|
+
request: makeRequest2(),
|
|
5854
|
+
nativeStream: [errored("Unavailable.")],
|
|
5855
|
+
expected: [
|
|
5856
|
+
sessionEvent2(NEW_SESSION_ID),
|
|
5857
|
+
{ type: "runtime_notice", body: "Unavailable." },
|
|
5858
|
+
{ type: "result", ok: false, reason: "error:Unavailable." }
|
|
5859
|
+
]
|
|
5762
5860
|
},
|
|
5763
5861
|
{
|
|
5764
5862
|
// CT1144: the same zero-content success the claude-code suite pins, in
|
|
@@ -6180,6 +6278,10 @@ async function* decodeCodexStream(events, ctx) {
|
|
|
6180
6278
|
yield* flushInterim();
|
|
6181
6279
|
ok = false;
|
|
6182
6280
|
const text = readErrorMessage(ev);
|
|
6281
|
+
const notice = text.trim().slice(0, 200).trimEnd();
|
|
6282
|
+
if (notice && !/^[\w:-]+$/.test(notice)) {
|
|
6283
|
+
yield { type: "runtime_notice", body: notice };
|
|
6284
|
+
}
|
|
6183
6285
|
const failure = classifyErrorText(text);
|
|
6184
6286
|
reason = failure ? encodeFailureReason(failure) : `error:${text.slice(0, 200)}`;
|
|
6185
6287
|
settled = true;
|
|
@@ -6498,6 +6600,7 @@ var codexAdapter = createCodexAdapter();
|
|
|
6498
6600
|
// packages/agent-runtime/src/codex/conformance.ts
|
|
6499
6601
|
var ABORT_SENTINEL3 = { __abortHere: true };
|
|
6500
6602
|
var NEW_THREAD_ID = "th_new";
|
|
6603
|
+
var BOUNDED_NOTICE2 = "You've hit your usage limit \xB7 ".padEnd(200, "x");
|
|
6501
6604
|
var COMPANION_POLICY2 = {
|
|
6502
6605
|
hostFs: false,
|
|
6503
6606
|
web: true,
|
|
@@ -7027,6 +7130,7 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
7027
7130
|
expected: [
|
|
7028
7131
|
sessionEvent3(NEW_THREAD_ID),
|
|
7029
7132
|
{ type: "text", body: "Partial work.", terminal: false },
|
|
7133
|
+
{ type: "runtime_notice", body: "provider exploded" },
|
|
7030
7134
|
{ type: "result", ok: false, reason: "error:provider exploded" }
|
|
7031
7135
|
]
|
|
7032
7136
|
},
|
|
@@ -7043,6 +7147,7 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
7043
7147
|
expected: [
|
|
7044
7148
|
sessionEvent3(NEW_THREAD_ID),
|
|
7045
7149
|
{ type: "text", body: "Starting.", terminal: false },
|
|
7150
|
+
{ type: "runtime_notice", body: "stream died" },
|
|
7046
7151
|
{ type: "result", ok: false, reason: "error:stream died" }
|
|
7047
7152
|
]
|
|
7048
7153
|
},
|
|
@@ -7055,25 +7160,47 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
7055
7160
|
name: "turn.failed 429 \u2192 rate_limited (throttle)",
|
|
7056
7161
|
request: makeRequest3(),
|
|
7057
7162
|
nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("429 Too Many Requests")],
|
|
7058
|
-
expected: [
|
|
7163
|
+
expected: [
|
|
7164
|
+
sessionEvent3(NEW_THREAD_ID),
|
|
7165
|
+
{ type: "runtime_notice", body: "429 Too Many Requests" },
|
|
7166
|
+
{ type: "result", ok: false, reason: "rate_limited" }
|
|
7167
|
+
]
|
|
7059
7168
|
},
|
|
7060
7169
|
{
|
|
7061
7170
|
name: "turn.failed overload \u2192 server_error",
|
|
7062
7171
|
request: makeRequest3(),
|
|
7063
7172
|
nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("503 Service Unavailable")],
|
|
7064
|
-
expected: [
|
|
7173
|
+
expected: [
|
|
7174
|
+
sessionEvent3(NEW_THREAD_ID),
|
|
7175
|
+
{ type: "runtime_notice", body: "503 Service Unavailable" },
|
|
7176
|
+
{ type: "result", ok: false, reason: "server_error" }
|
|
7177
|
+
]
|
|
7065
7178
|
},
|
|
7066
7179
|
{
|
|
7067
7180
|
name: "turn.failed usage cap \u2192 usage_capped",
|
|
7068
7181
|
request: makeRequest3(),
|
|
7069
|
-
nativeStream: [
|
|
7070
|
-
|
|
7182
|
+
nativeStream: [
|
|
7183
|
+
threadStarted(NEW_THREAD_ID),
|
|
7184
|
+
turnFailed("You've hit your session limit \xB7 resets 8:20pm (UTC)")
|
|
7185
|
+
],
|
|
7186
|
+
expected: [
|
|
7187
|
+
sessionEvent3(NEW_THREAD_ID),
|
|
7188
|
+
{
|
|
7189
|
+
type: "runtime_notice",
|
|
7190
|
+
body: "You've hit your session limit \xB7 resets 8:20pm (UTC)"
|
|
7191
|
+
},
|
|
7192
|
+
{ type: "result", ok: false, reason: "usage_capped" }
|
|
7193
|
+
]
|
|
7071
7194
|
},
|
|
7072
7195
|
{
|
|
7073
7196
|
name: "turn.failed auth \u2192 auth_expired",
|
|
7074
7197
|
request: makeRequest3(),
|
|
7075
7198
|
nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("401 Unauthorized: invalid api key")],
|
|
7076
|
-
expected: [
|
|
7199
|
+
expected: [
|
|
7200
|
+
sessionEvent3(NEW_THREAD_ID),
|
|
7201
|
+
{ type: "runtime_notice", body: "401 Unauthorized: invalid api key" },
|
|
7202
|
+
{ type: "result", ok: false, reason: "auth_expired" }
|
|
7203
|
+
]
|
|
7077
7204
|
},
|
|
7078
7205
|
{
|
|
7079
7206
|
// CT592: a Codex failure text the classifier can't place still keeps today's
|
|
@@ -7083,9 +7210,53 @@ var CODEX_CONFORMANCE_FIXTURES = [
|
|
|
7083
7210
|
nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("something local broke")],
|
|
7084
7211
|
expected: [
|
|
7085
7212
|
sessionEvent3(NEW_THREAD_ID),
|
|
7213
|
+
{ type: "runtime_notice", body: "something local broke" },
|
|
7086
7214
|
{ type: "result", ok: false, reason: "error:something local broke" }
|
|
7087
7215
|
]
|
|
7088
7216
|
},
|
|
7217
|
+
{
|
|
7218
|
+
name: "runtime notice is trimmed and bounded to 200 characters",
|
|
7219
|
+
request: makeRequest3(),
|
|
7220
|
+
nativeStream: [
|
|
7221
|
+
threadStarted(NEW_THREAD_ID),
|
|
7222
|
+
turnFailed(` ${BOUNDED_NOTICE2}${"y".repeat(20)} `)
|
|
7223
|
+
],
|
|
7224
|
+
expected: [
|
|
7225
|
+
sessionEvent3(NEW_THREAD_ID),
|
|
7226
|
+
{ type: "runtime_notice", body: BOUNDED_NOTICE2 },
|
|
7227
|
+
{ type: "result", ok: false, reason: "usage_capped" }
|
|
7228
|
+
]
|
|
7229
|
+
},
|
|
7230
|
+
{
|
|
7231
|
+
// CT1351: empty, whitespace-only, and bare status-token failures still set
|
|
7232
|
+
// the machine reason but do not add an empty/unhelpful transcript notice.
|
|
7233
|
+
name: "empty failure text emits no runtime notice",
|
|
7234
|
+
request: makeRequest3(),
|
|
7235
|
+
nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("")],
|
|
7236
|
+
expected: [sessionEvent3(NEW_THREAD_ID), { type: "result", ok: false, reason: "error:unknown" }]
|
|
7237
|
+
},
|
|
7238
|
+
{
|
|
7239
|
+
name: "whitespace-only failure text emits no runtime notice",
|
|
7240
|
+
request: makeRequest3(),
|
|
7241
|
+
nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed(" ")],
|
|
7242
|
+
expected: [sessionEvent3(NEW_THREAD_ID), { type: "result", ok: false, reason: "error: " }]
|
|
7243
|
+
},
|
|
7244
|
+
{
|
|
7245
|
+
name: "bare status token emits no runtime notice",
|
|
7246
|
+
request: makeRequest3(),
|
|
7247
|
+
nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("429")],
|
|
7248
|
+
expected: [sessionEvent3(NEW_THREAD_ID), { type: "result", ok: false, reason: "rate_limited" }]
|
|
7249
|
+
},
|
|
7250
|
+
{
|
|
7251
|
+
name: "single-word provider sentence emits a runtime notice",
|
|
7252
|
+
request: makeRequest3(),
|
|
7253
|
+
nativeStream: [threadStarted(NEW_THREAD_ID), turnFailed("Unavailable.")],
|
|
7254
|
+
expected: [
|
|
7255
|
+
sessionEvent3(NEW_THREAD_ID),
|
|
7256
|
+
{ type: "runtime_notice", body: "Unavailable." },
|
|
7257
|
+
{ type: "result", ok: false, reason: "error:Unavailable." }
|
|
7258
|
+
]
|
|
7259
|
+
},
|
|
7089
7260
|
{
|
|
7090
7261
|
// CT481: fail LOUD on unknown model metadata. An unknown `--model` makes Codex
|
|
7091
7262
|
// emit an `error` item ("… Defaulting to fallback metadata …") and then go
|
|
@@ -7392,10 +7563,11 @@ import { existsSync as existsSync11, readdirSync as readdirSync2, statSync } fro
|
|
|
7392
7563
|
import { join as join14 } from "path";
|
|
7393
7564
|
|
|
7394
7565
|
// src/turn-execution.ts
|
|
7395
|
-
import { createHash as createHash2, randomUUID } from "crypto";
|
|
7566
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
|
|
7396
7567
|
import { existsSync as existsSync10 } from "fs";
|
|
7397
7568
|
|
|
7398
7569
|
// src/turn-control-tools.ts
|
|
7570
|
+
import { randomUUID } from "crypto";
|
|
7399
7571
|
import { z as z13 } from "zod";
|
|
7400
7572
|
var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
|
|
7401
7573
|
var SEND_TOOL = "send";
|
|
@@ -7411,89 +7583,83 @@ var WAKE_ME_TOOL = "wake_me";
|
|
|
7411
7583
|
var WAKE_ME_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${WAKE_ME_TOOL}`;
|
|
7412
7584
|
var CANCEL_WAKE_TOOL = "cancel_wake";
|
|
7413
7585
|
var CANCEL_WAKE_TOOL_NAME = `mcp__${COMPANION_LOCAL_MCP_SERVER}__${CANCEL_WAKE_TOOL}`;
|
|
7586
|
+
var TURN_ACT_TIMEOUT_MS = 45e3;
|
|
7414
7587
|
function createReplyState() {
|
|
7415
|
-
return { answersMessageId: null
|
|
7416
|
-
}
|
|
7417
|
-
function createSendState() {
|
|
7418
|
-
return { agentId: null, message: null, order: null };
|
|
7419
|
-
}
|
|
7420
|
-
function createTurnControlOrder() {
|
|
7421
|
-
let calls = 0;
|
|
7422
|
-
return {
|
|
7423
|
-
next: () => {
|
|
7424
|
-
calls += 1;
|
|
7425
|
-
return calls;
|
|
7426
|
-
}
|
|
7427
|
-
};
|
|
7588
|
+
return { answersMessageId: null };
|
|
7428
7589
|
}
|
|
7429
7590
|
function createSkipState() {
|
|
7430
7591
|
return { skipped: false, reason: null };
|
|
7431
7592
|
}
|
|
7432
|
-
function createAskState() {
|
|
7433
|
-
return { targetUserId: null, question: null, headline: null, options: null, questions: null };
|
|
7434
|
-
}
|
|
7435
|
-
function createWakeState() {
|
|
7436
|
-
return { afterSeconds: null, at: null, note: null, cancelled: false };
|
|
7437
|
-
}
|
|
7438
7593
|
function resolveDeclaredReplyField(input) {
|
|
7439
7594
|
const explicit = input.replyState.answersMessageId;
|
|
7440
7595
|
if (explicit) return { answersMessageId: explicit };
|
|
7441
7596
|
if (input.kind !== "final") return {};
|
|
7442
7597
|
if (!input.owedReplyMessageId) return {};
|
|
7443
|
-
const outwardSend = Boolean(
|
|
7444
|
-
input.sendState.agentId && input.sendState.message && input.sendState.agentId !== input.agentId
|
|
7445
|
-
);
|
|
7446
|
-
const askRaised = Boolean(input.askState.targetUserId);
|
|
7447
|
-
const wakeArmed = input.wakeState.afterSeconds !== null || input.wakeState.at !== null;
|
|
7448
|
-
if (outwardSend || askRaised || wakeArmed) return {};
|
|
7449
7598
|
return { answersMessageId: input.owedReplyMessageId, answersAutoDeclared: true };
|
|
7450
7599
|
}
|
|
7451
|
-
function
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7600
|
+
function toolError(text) {
|
|
7601
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
7602
|
+
}
|
|
7603
|
+
async function performAct(acts, act) {
|
|
7604
|
+
let result;
|
|
7605
|
+
try {
|
|
7606
|
+
result = await acts.api.turnAct(
|
|
7607
|
+
acts.workspaceId,
|
|
7608
|
+
acts.turnId,
|
|
7609
|
+
act,
|
|
7610
|
+
AbortSignal.timeout(TURN_ACT_TIMEOUT_MS)
|
|
7611
|
+
);
|
|
7612
|
+
} catch (err) {
|
|
7613
|
+
if (err instanceof ApiError) {
|
|
7614
|
+
if (err.status === 409) {
|
|
7615
|
+
return toolError(
|
|
7616
|
+
"No active turn: this turn has already settled, so the act was not performed."
|
|
7617
|
+
);
|
|
7618
|
+
}
|
|
7619
|
+
return toolError(`The act was refused (${err.status}): ${err.message}`);
|
|
7460
7620
|
}
|
|
7461
|
-
|
|
7621
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
7622
|
+
return toolError(
|
|
7623
|
+
`Cabane could not be reached (${message}), so it is UNKNOWN whether this act was recorded \u2014 it may have committed with only the response lost. Do NOT simply repeat the call: a repeat is a second, separate act, not a retry. Carry on, and say in your reply that you could not confirm it.`
|
|
7624
|
+
);
|
|
7625
|
+
}
|
|
7626
|
+
if (!result.ok) return toolError(JSON.stringify(result));
|
|
7627
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
7462
7628
|
}
|
|
7463
|
-
function createTurnControlMcpServer(
|
|
7629
|
+
function createTurnControlMcpServer(acts, skipState, replyState) {
|
|
7630
|
+
const ACT_TOOL = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
|
|
7631
|
+
const CANCEL_TOOL = { ...ACT_TOOL, idempotentHint: true };
|
|
7632
|
+
const RECORDER_TOOL = { readOnlyHint: true, openWorldHint: false };
|
|
7464
7633
|
return createSdkMcpServer({
|
|
7465
7634
|
name: COMPANION_LOCAL_MCP_SERVER,
|
|
7466
7635
|
version: "0.0.0",
|
|
7467
7636
|
tools: [
|
|
7468
7637
|
tool(
|
|
7469
7638
|
SEND_TOOL,
|
|
7470
|
-
"Send one addressed message to another agent in THIS conversation. Pass the peer's `agentId` and the complete `message` they should act on
|
|
7639
|
+
"Send one addressed message to another agent in THIS conversation. Pass the peer's `agentId` and the complete `message` they should act on. The message is WRITTEN the moment you call this \u2014 durable, in the conversation's queue, in the order you called \u2014 but it is DELIVERED only after your own turn ends: the peer is not running yet and cannot answer you inside this turn. Each call is one message; two calls are two messages. Writing `@handle` in prose dispatches nobody. An unknown, deactivated, or self target is refused here, in the result. A handoff to a DIFFERENT conversation is `conversations.create` / `conversations.post` with their `dispatch` field instead. Never use send to hand an answer back to the requester \u2014 declare that with reply_to.",
|
|
7471
7640
|
{
|
|
7472
7641
|
agentId: z13.string().uuid().describe(
|
|
7473
7642
|
"The peer agent to address \u2014 a workspace agent id, from your turn context's roster."
|
|
7474
7643
|
),
|
|
7475
7644
|
message: z13.string().min(1).max(65536).describe("The complete new request the peer should receive and act on.")
|
|
7476
7645
|
},
|
|
7477
|
-
async (args) => {
|
|
7478
|
-
|
|
7479
|
-
|
|
7480
|
-
|
|
7481
|
-
|
|
7482
|
-
|
|
7483
|
-
|
|
7484
|
-
},
|
|
7485
|
-
{ annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
|
|
7646
|
+
async (args) => performAct(acts, {
|
|
7647
|
+
kind: "send",
|
|
7648
|
+
callId: randomUUID(),
|
|
7649
|
+
agentId: args.agentId,
|
|
7650
|
+
message: args.message
|
|
7651
|
+
}),
|
|
7652
|
+
{ annotations: ACT_TOOL, alwaysLoad: true }
|
|
7486
7653
|
),
|
|
7487
7654
|
...replyState ? [
|
|
7488
7655
|
tool(
|
|
7489
7656
|
REPLY_TO_TOOL,
|
|
7490
|
-
"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
|
|
7657
|
+
"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 \u2014 a later call replaces the earlier declaration.",
|
|
7491
7658
|
{
|
|
7492
7659
|
messageId: z13.string().uuid().describe("The owed ask message id from this turn context.")
|
|
7493
7660
|
},
|
|
7494
7661
|
async (args) => {
|
|
7495
7662
|
replyState.answersMessageId = args.messageId;
|
|
7496
|
-
replyState.order = controlOrder?.next() ?? null;
|
|
7497
7663
|
return {
|
|
7498
7664
|
content: [
|
|
7499
7665
|
{
|
|
@@ -7503,7 +7669,7 @@ function createTurnControlMcpServer(sendState, skipState, askState, wakeState, r
|
|
|
7503
7669
|
]
|
|
7504
7670
|
};
|
|
7505
7671
|
},
|
|
7506
|
-
{ annotations:
|
|
7672
|
+
{ annotations: RECORDER_TOOL, alwaysLoad: true }
|
|
7507
7673
|
)
|
|
7508
7674
|
] : [],
|
|
7509
7675
|
...skipState ? [
|
|
@@ -7520,161 +7686,112 @@ function createTurnControlMcpServer(sendState, skipState, askState, wakeState, r
|
|
|
7520
7686
|
content: [{ type: "text", text: JSON.stringify({ skipped: true }) }]
|
|
7521
7687
|
};
|
|
7522
7688
|
},
|
|
7523
|
-
{ annotations:
|
|
7524
|
-
)
|
|
7525
|
-
] : [],
|
|
7526
|
-
...askState ? [
|
|
7527
|
-
tool(
|
|
7528
|
-
ASK_TOOL,
|
|
7529
|
-
`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.`,
|
|
7530
|
-
{
|
|
7531
|
-
targetUserId: z13.string().uuid().describe(
|
|
7532
|
-
"The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
|
|
7533
|
-
),
|
|
7534
|
-
question: z13.string().min(1).max(400).optional().describe(
|
|
7535
|
-
"SINGLE-question form: a short body \u2014 one or two sentences of framing the headline can't hold. NOT a report (capped, inline markdown only). Provide EITHER this or `questions`, not both. Put the crisp one-sentence question in `headline`."
|
|
7536
|
-
),
|
|
7537
|
-
headline: z13.string().min(1).max(120).optional().describe(
|
|
7538
|
-
'SINGLE-question form: the question itself as ONE clear, capitalized sentence ending in `?` ("Do we go to prod?"). What the human reads first in the inbox and the chip \u2014 one scannable question, no elaboration (that goes in `question`). Strongly encouraged.'
|
|
7539
|
-
),
|
|
7540
|
-
options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("SINGLE-question form: optional 2\u20134 suggested one-click answers."),
|
|
7541
|
-
questions: z13.array(
|
|
7542
|
-
z13.object({
|
|
7543
|
-
headline: z13.string().min(1).max(120).describe(
|
|
7544
|
-
'The one-sentence question ("Do we go to prod?") \u2014 required for each item.'
|
|
7545
|
-
),
|
|
7546
|
-
body: z13.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
|
|
7547
|
-
options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
|
|
7548
|
-
})
|
|
7549
|
-
).min(1).max(MAX_ASK_ITEMS).optional().describe(
|
|
7550
|
-
`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.`
|
|
7551
|
-
)
|
|
7552
|
-
},
|
|
7553
|
-
async (args) => {
|
|
7554
|
-
const hasSingle = args.question !== void 0;
|
|
7555
|
-
const hasArray = args.questions !== void 0 && args.questions.length > 0;
|
|
7556
|
-
if (hasSingle && hasArray) {
|
|
7557
|
-
return {
|
|
7558
|
-
isError: true,
|
|
7559
|
-
content: [
|
|
7560
|
-
{
|
|
7561
|
-
type: "text",
|
|
7562
|
-
text: "Provide either `question` (single) or `questions` (array), not both."
|
|
7563
|
-
}
|
|
7564
|
-
]
|
|
7565
|
-
};
|
|
7566
|
-
}
|
|
7567
|
-
if (!hasSingle && !hasArray) {
|
|
7568
|
-
return {
|
|
7569
|
-
isError: true,
|
|
7570
|
-
content: [
|
|
7571
|
-
{
|
|
7572
|
-
type: "text",
|
|
7573
|
-
text: "Provide `question` (single) or `questions` (array)."
|
|
7574
|
-
}
|
|
7575
|
-
]
|
|
7576
|
-
};
|
|
7577
|
-
}
|
|
7578
|
-
askState.targetUserId = args.targetUserId;
|
|
7579
|
-
if (hasArray) {
|
|
7580
|
-
askState.questions = args.questions;
|
|
7581
|
-
askState.question = null;
|
|
7582
|
-
askState.headline = null;
|
|
7583
|
-
askState.options = null;
|
|
7584
|
-
} else {
|
|
7585
|
-
askState.question = args.question;
|
|
7586
|
-
askState.headline = args.headline ?? null;
|
|
7587
|
-
askState.options = args.options ?? null;
|
|
7588
|
-
askState.questions = null;
|
|
7589
|
-
}
|
|
7590
|
-
return {
|
|
7591
|
-
content: [
|
|
7592
|
-
{ type: "text", text: JSON.stringify({ asked: args.targetUserId }) }
|
|
7593
|
-
]
|
|
7594
|
-
};
|
|
7595
|
-
},
|
|
7596
|
-
{ annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
|
|
7689
|
+
{ annotations: RECORDER_TOOL, alwaysLoad: true }
|
|
7597
7690
|
)
|
|
7598
7691
|
] : [],
|
|
7599
|
-
|
|
7600
|
-
|
|
7601
|
-
|
|
7602
|
-
|
|
7603
|
-
|
|
7604
|
-
|
|
7605
|
-
|
|
7606
|
-
|
|
7607
|
-
|
|
7608
|
-
|
|
7609
|
-
|
|
7610
|
-
|
|
7611
|
-
|
|
7612
|
-
|
|
7613
|
-
|
|
7614
|
-
|
|
7615
|
-
|
|
7616
|
-
|
|
7617
|
-
|
|
7618
|
-
|
|
7619
|
-
|
|
7620
|
-
|
|
7621
|
-
|
|
7622
|
-
|
|
7623
|
-
|
|
7624
|
-
|
|
7625
|
-
|
|
7626
|
-
|
|
7692
|
+
tool(
|
|
7693
|
+
ASK_TOOL,
|
|
7694
|
+
`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 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; inline markdown only, no 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. 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 CONTEXT, the ask carries the QUESTION(S). It is written and delivered THE MOMENT you call this \u2014 the person sees it in their inbox while your turn is still running \u2014 so call it once you know the question; a second call is a second question, not an edit. The human sends ONE response: supplied answers are recorded, omissions become terminally Unanswered. Ask only when blocked \u2014 never ceremonially. After asking, finish your reply and stop; when the person replies addressed to you, the ask resolves and you resume. Targets a human only; to hand work to another AGENT use send/dispatch instead.`,
|
|
7695
|
+
{
|
|
7696
|
+
targetUserId: z13.string().uuid().describe(
|
|
7697
|
+
"The workspace member (human) to ask \u2014 a user id, from your turn context's roster."
|
|
7698
|
+
),
|
|
7699
|
+
question: z13.string().min(1).max(400).optional().describe(
|
|
7700
|
+
"SINGLE-question form: a short body \u2014 one or two sentences of framing the headline can't hold. NOT a report (capped, inline markdown only). Provide EITHER this or `questions`, not both. Put the crisp one-sentence question in `headline`."
|
|
7701
|
+
),
|
|
7702
|
+
headline: z13.string().min(1).max(120).optional().describe(
|
|
7703
|
+
'SINGLE-question form: the question itself as ONE clear, capitalized sentence ending in `?` ("Do we go to prod?"). What the human reads first in the inbox and the chip \u2014 one scannable question, no elaboration (that goes in `question`). Strongly encouraged.'
|
|
7704
|
+
),
|
|
7705
|
+
options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("SINGLE-question form: optional 2\u20134 suggested one-click answers."),
|
|
7706
|
+
questions: z13.array(
|
|
7707
|
+
z13.object({
|
|
7708
|
+
headline: z13.string().min(1).max(120).describe(
|
|
7709
|
+
'The one-sentence question ("Do we go to prod?") \u2014 required for each item.'
|
|
7710
|
+
),
|
|
7711
|
+
body: z13.string().min(1).max(400).optional().describe("Optional short framing beneath the headline. NOT a report."),
|
|
7712
|
+
options: z13.array(z13.string().min(1).max(200)).min(2).max(4).optional().describe("Optional 2\u20134 one-click answers for this question.")
|
|
7713
|
+
})
|
|
7714
|
+
).min(1).max(MAX_ASK_ITEMS).optional().describe(
|
|
7715
|
+
`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.`
|
|
7716
|
+
)
|
|
7717
|
+
},
|
|
7718
|
+
async (args) => {
|
|
7719
|
+
const hasSingle = args.question !== void 0;
|
|
7720
|
+
const hasArray = args.questions !== void 0 && args.questions.length > 0;
|
|
7721
|
+
if (hasSingle && hasArray) {
|
|
7722
|
+
return toolError(
|
|
7723
|
+
"Provide either `question` (single) or `questions` (array), not both."
|
|
7724
|
+
);
|
|
7725
|
+
}
|
|
7726
|
+
if (!hasSingle && !hasArray) {
|
|
7727
|
+
return toolError("Provide `question` (single) or `questions` (array).");
|
|
7728
|
+
}
|
|
7729
|
+
return performAct(acts, {
|
|
7730
|
+
kind: "ask",
|
|
7731
|
+
callId: randomUUID(),
|
|
7732
|
+
ask: hasArray ? {
|
|
7733
|
+
targetUserId: args.targetUserId,
|
|
7734
|
+
questions: args.questions.map((q) => ({
|
|
7735
|
+
headline: q.headline,
|
|
7736
|
+
...q.body ? { body: q.body } : {},
|
|
7737
|
+
...q.options && q.options.length > 0 ? { options: q.options } : {}
|
|
7738
|
+
}))
|
|
7739
|
+
} : {
|
|
7740
|
+
targetUserId: args.targetUserId,
|
|
7741
|
+
question: args.question,
|
|
7742
|
+
...args.headline ? { headline: args.headline } : {},
|
|
7743
|
+
...args.options && args.options.length > 0 ? { options: args.options } : {}
|
|
7627
7744
|
}
|
|
7628
|
-
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7745
|
+
});
|
|
7746
|
+
},
|
|
7747
|
+
{ annotations: ACT_TOOL, alwaysLoad: true }
|
|
7748
|
+
),
|
|
7749
|
+
tool(
|
|
7750
|
+
WAKE_ME_TOOL,
|
|
7751
|
+
"Wake yourself later \u2014 end this turn now and be re-dispatched at a time you pick, with a note you write to yourself. Use it for \"wait until X\": when the thing you need hasn't happened yet (a PR isn't merged, a human hasn't answered), arm a wake, end your turn, and you're woken later to CHECK \u2014 read the workspace, and either act or re-arm. Ground the delay before you arm it. Almost every wake is short \u2014 seconds to a couple of hours \u2014 waiting on a condition you can name: a session limit resetting, a PR merging. Reach past a few hours only when (a) a human asked for that timing, or (b) the wait is pinned to a real external event you can name \u2014 a report that only runs Mondays, a known reset time. A speculative far-future check-in you invented yourself is the one thing not to arm: if no one asked and you can't name both what clears the wait and why it takes that long, don't arm it \u2014 finish now, or raise an `ask`. Pass EXACTLY ONE of `afterSeconds` (a relative delay \u2014 `300` for five minutes) or `at` (an absolute ISO-8601 timestamp WITH a zone, e.g. `2026-07-16T09:00:00-07:00` \u2014 YOU compute it; the system never parses natural-language time). `note` is a message to your future self \u2014 it becomes the body of the wake message that re-dispatches you, so write the condition to re-check (\"check whether CT441 merged yet\"). The wake arms NOW, when you call this, and the delay counts from this call; a later `wake_me` in the same turn replaces it, and `cancel_wake` stands it down. A wake that comes due while your turn is still running waits and is delivered after your reply. This is the ONLY sanctioned way to schedule your own continuation; never reach for a host cron/scheduler. Guardrails: at least 60s out, at most 14 days \u2014 a wake that can't be armed returns an error here, in this call, so you can recompute; widen the interval as a loop ages (5m \u2192 15m \u2192 1h\u2026) rather than hammering; after many consecutive re-arms with no other activity you'll be steered to raise an `ask` to the human instead.",
|
|
7752
|
+
{
|
|
7753
|
+
afterSeconds: z13.number().int().positive().optional().describe(
|
|
7754
|
+
"Relative delay in seconds from NOW \u2014 this call (e.g. 300 = five minutes). Provide EITHER this or `at`, not both. Floor 60s, horizon 14 days \u2014 enforced server-side."
|
|
7755
|
+
),
|
|
7756
|
+
at: z13.string().datetime({ offset: true }).optional().describe(
|
|
7757
|
+
"Absolute ISO-8601 timestamp WITH a zone (`Z` or `\xB1HH:MM`), e.g. `2026-07-16T09:00:00-07:00`. YOU compute it from a natural-language phrase using the current datetime in your turn context. Provide EITHER this or `afterSeconds`, not both."
|
|
7758
|
+
),
|
|
7759
|
+
note: z13.string().min(1).max(2e3).describe(
|
|
7760
|
+
'A note to your future self \u2014 becomes the body of the wake message that re-dispatches you. Write the condition to re-check ("check whether the PR merged").'
|
|
7761
|
+
)
|
|
7762
|
+
},
|
|
7763
|
+
async (args) => {
|
|
7764
|
+
const hasAfter = args.afterSeconds !== void 0;
|
|
7765
|
+
const hasAt = args.at !== void 0;
|
|
7766
|
+
if (hasAfter && hasAt) {
|
|
7767
|
+
return toolError(
|
|
7768
|
+
"Provide either `afterSeconds` (relative) or `at` (absolute), not both."
|
|
7769
|
+
);
|
|
7770
|
+
}
|
|
7771
|
+
if (!hasAfter && !hasAt) {
|
|
7772
|
+
return toolError("Provide `afterSeconds` or `at`.");
|
|
7773
|
+
}
|
|
7774
|
+
return performAct(acts, {
|
|
7775
|
+
kind: "wake",
|
|
7776
|
+
callId: randomUUID(),
|
|
7777
|
+
wake: {
|
|
7778
|
+
...hasAfter ? { afterSeconds: args.afterSeconds } : {},
|
|
7779
|
+
...hasAt ? { at: args.at } : {},
|
|
7780
|
+
note: args.note
|
|
7633
7781
|
}
|
|
7634
|
-
|
|
7635
|
-
|
|
7636
|
-
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
7644
|
-
|
|
7645
|
-
|
|
7646
|
-
|
|
7647
|
-
]
|
|
7648
|
-
};
|
|
7649
|
-
},
|
|
7650
|
-
{ annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
|
|
7651
|
-
),
|
|
7652
|
-
// CT990: the inverse. No arguments — it can only ever stand down the
|
|
7653
|
-
// caller's own wake in the conversation it's holding a turn in.
|
|
7654
|
-
tool(
|
|
7655
|
-
CANCEL_WAKE_TOOL,
|
|
7656
|
-
'Stand down your own wake \u2014 the inverse of `wake_me`. No arguments: it means "when this turn settles, leave no wake armed for me in this conversation." Reach for it when the thing you armed a wake to check has already happened, or the work it was watching is over \u2014 an armed wake you no longer need fires into a turn with nothing to do, and a supervision loop with no off switch is one you can only end by leaving it running. Whether you have one armed is printed in your turn context ("Wake armed: \u2026"); calling this with nothing armed is a clean no-op, no error. It writes the same per-turn slot as `wake_me`, so the two are last-call-wins against each other: `cancel_wake` then `wake_me` leaves the NEW wake armed, `wake_me` then `cancel_wake` leaves nothing armed. Like `wake_me` it takes effect when the turn SETTLES, not now. It reaches only your own wake in this conversation \u2014 never a peer\'s, and never a recurring schedule (those are `schedules.*`).',
|
|
7657
|
-
{},
|
|
7658
|
-
async () => {
|
|
7659
|
-
wakeState.cancelled = true;
|
|
7660
|
-
wakeState.afterSeconds = null;
|
|
7661
|
-
wakeState.at = null;
|
|
7662
|
-
wakeState.note = null;
|
|
7663
|
-
return {
|
|
7664
|
-
content: [
|
|
7665
|
-
{
|
|
7666
|
-
type: "text",
|
|
7667
|
-
text: JSON.stringify({
|
|
7668
|
-
cancelled: true,
|
|
7669
|
-
note: "Recorded. Any wake you have armed in this conversation is stood down when this turn ends. If you had none, nothing happens."
|
|
7670
|
-
})
|
|
7671
|
-
}
|
|
7672
|
-
]
|
|
7673
|
-
};
|
|
7674
|
-
},
|
|
7675
|
-
{ annotations: { readOnlyHint: true, openWorldHint: false }, alwaysLoad: true }
|
|
7676
|
-
)
|
|
7677
|
-
] : []
|
|
7782
|
+
});
|
|
7783
|
+
},
|
|
7784
|
+
{ annotations: ACT_TOOL, alwaysLoad: true }
|
|
7785
|
+
),
|
|
7786
|
+
// CT990: the inverse. No arguments — it can only ever stand down the
|
|
7787
|
+
// caller's own wake in the conversation it's holding a turn in.
|
|
7788
|
+
tool(
|
|
7789
|
+
CANCEL_WAKE_TOOL,
|
|
7790
|
+
'Stand down your own wake \u2014 the inverse of `wake_me`. No arguments: it cancels the wake you have armed in this conversation, NOW, when you call it. Reach for it when the thing you armed a wake to check has already happened, or the work it was watching is over \u2014 an armed wake you no longer need fires into a turn with nothing to do, and a supervision loop with no off switch is one you can only end by leaving it running. Whether you have one armed is printed in your turn context ("Wake armed: \u2026"); calling this with nothing armed is a clean no-op, no error. Each call acts as it arrives: `cancel_wake` then `wake_me` leaves the NEW wake armed; `wake_me` then `cancel_wake` leaves nothing armed. It reaches only your own wake in this conversation \u2014 never a peer\'s, and never a recurring schedule (those are `schedules.*`).',
|
|
7791
|
+
{},
|
|
7792
|
+
async () => performAct(acts, { kind: "cancel_wake", callId: randomUUID() }),
|
|
7793
|
+
{ annotations: CANCEL_TOOL, alwaysLoad: true }
|
|
7794
|
+
)
|
|
7678
7795
|
]
|
|
7679
7796
|
});
|
|
7680
7797
|
}
|
|
@@ -8101,107 +8218,25 @@ var TurnCommitter = class {
|
|
|
8101
8218
|
return this.pump.finalEmitted;
|
|
8102
8219
|
}
|
|
8103
8220
|
// A closing textual reply and a wordless terminal marker carry the same
|
|
8104
|
-
//
|
|
8105
|
-
//
|
|
8106
|
-
//
|
|
8107
|
-
// a wordless turn has no answer to bind, and the server's mute-settle
|
|
8108
|
-
// notice speaks for it.
|
|
8221
|
+
// declared-reply projection. The KIND matters: the auto-declared reply binds
|
|
8222
|
+
// only a textual `final` — a wordless turn has no answer to bind, and the
|
|
8223
|
+
// server's mute-settle notice speaks for it.
|
|
8109
8224
|
turnControlFields(kind) {
|
|
8110
|
-
|
|
8111
|
-
...this.answersField(kind),
|
|
8112
|
-
...this.sendField(),
|
|
8113
|
-
...this.askField(),
|
|
8114
|
-
...this.wakeField()
|
|
8115
|
-
};
|
|
8116
|
-
return { ...fields, ...this.orderField(fields) };
|
|
8117
|
-
}
|
|
8118
|
-
// CT1281: the order the control tools were CALLED, for the addressed rows the
|
|
8119
|
-
// server writes from this one commit. It reports a position only for an intent
|
|
8120
|
-
// that actually made it into the commit — a self-send the committer stripped
|
|
8121
|
-
// contributes no row and so has no place in the line. An intent with no
|
|
8122
|
-
// recorded call (the runtime's auto-declared reply) is deliberately absent, and
|
|
8123
|
-
// the server sorts it after every recorded one — it binds the turn's closing
|
|
8124
|
-
// words.
|
|
8125
|
-
//
|
|
8126
|
-
// The ask is absent by design, not by omission: its carrier queues last among
|
|
8127
|
-
// the turn's rows however early the tool fired, so there is no position to
|
|
8128
|
-
// report.
|
|
8129
|
-
orderField(fields) {
|
|
8130
|
-
const order = {};
|
|
8131
|
-
const replyOrder = this.deps.replyState.order;
|
|
8132
|
-
if (fields.answersMessageId !== void 0 && replyOrder !== null) order.reply = replyOrder;
|
|
8133
|
-
if (fields.dispatch !== void 0 && this.deps.sendState.order !== null) {
|
|
8134
|
-
order.send = this.deps.sendState.order;
|
|
8135
|
-
}
|
|
8136
|
-
return Object.keys(order).length > 0 ? { turnControlOrder: order } : {};
|
|
8225
|
+
return this.answersField(kind);
|
|
8137
8226
|
}
|
|
8138
8227
|
// The declared reply: explicit `reply_to` first, else the runtime's own
|
|
8139
8228
|
// declaration for the turn that just answers — see
|
|
8140
8229
|
// `resolveDeclaredReplyField` for the whole rule and its reasons. CT1224: an
|
|
8141
|
-
// auto-declaration is flagged as such on the wire, because the server drops
|
|
8142
|
-
// when the turn
|
|
8143
|
-
// `reply_to` carries no flag and always stands.
|
|
8230
|
+
// auto-declaration is flagged as such on the wire, because the server drops
|
|
8231
|
+
// it when the turn performed an outward act (CT1354: any of them, read off
|
|
8232
|
+
// the ledger). An explicit `reply_to` carries no flag and always stands.
|
|
8144
8233
|
answersField(kind) {
|
|
8145
8234
|
return resolveDeclaredReplyField({
|
|
8146
8235
|
kind,
|
|
8147
8236
|
owedReplyMessageId: this.deps.owedReplyMessageId,
|
|
8148
|
-
|
|
8149
|
-
replyState: this.deps.replyState,
|
|
8150
|
-
sendState: this.deps.sendState,
|
|
8151
|
-
askState: this.deps.askState,
|
|
8152
|
-
wakeState: this.deps.wakeState
|
|
8237
|
+
replyState: this.deps.replyState
|
|
8153
8238
|
});
|
|
8154
8239
|
}
|
|
8155
|
-
// Carry both halves of the addressed send. The server writes `dispatchBody`
|
|
8156
|
-
// on a distinct message row; the turn row itself remains unaddressed speech.
|
|
8157
|
-
sendField() {
|
|
8158
|
-
const { agentId: target, message } = this.deps.sendState;
|
|
8159
|
-
if (!target || !message || target === this.deps.agentId) return {};
|
|
8160
|
-
return { dispatch: target, dispatchBody: message };
|
|
8161
|
-
}
|
|
8162
|
-
// CT326: resolve the per-turn ask into the `ask` field for a `final` commit.
|
|
8163
|
-
// The server validates the target (must be a workspace member/owner) and
|
|
8164
|
-
// creates the `asks` row; an absent/incomplete ask attaches nothing. Kept
|
|
8165
|
-
// independent of `sendField` — a turn can send and ask independently.
|
|
8166
|
-
askField() {
|
|
8167
|
-
const { targetUserId, question, headline, options, questions } = this.deps.askState;
|
|
8168
|
-
if (!targetUserId) return {};
|
|
8169
|
-
if (questions && questions.length > 0) {
|
|
8170
|
-
return {
|
|
8171
|
-
ask: {
|
|
8172
|
-
targetUserId,
|
|
8173
|
-
questions: questions.map((q) => ({
|
|
8174
|
-
headline: q.headline,
|
|
8175
|
-
...q.body ? { body: q.body } : {},
|
|
8176
|
-
...q.options && q.options.length > 0 ? { options: q.options } : {}
|
|
8177
|
-
}))
|
|
8178
|
-
}
|
|
8179
|
-
};
|
|
8180
|
-
}
|
|
8181
|
-
if (!question) return {};
|
|
8182
|
-
return {
|
|
8183
|
-
ask: {
|
|
8184
|
-
targetUserId,
|
|
8185
|
-
question,
|
|
8186
|
-
// CT400: the optional one-sentence headline question, when supplied.
|
|
8187
|
-
...headline ? { headline } : {},
|
|
8188
|
-
...options && options.length > 0 ? { options } : {}
|
|
8189
|
-
}
|
|
8190
|
-
};
|
|
8191
|
-
}
|
|
8192
|
-
// CT442: resolve the per-turn wake into the `wake` field for a `final` commit.
|
|
8193
|
-
// The tool guarantees exactly one of `afterSeconds`/`at` is set once armed (both
|
|
8194
|
-
// null = no wake this turn → attach nothing). The server computes `fire_at` and
|
|
8195
|
-
// arms the CT441 schedule. Kept independent of send/ask — a turn could
|
|
8196
|
-
// conceivably ask AND arm a wake.
|
|
8197
|
-
//
|
|
8198
|
-
// CT990: the field now carries three states, and the third can't be spelled by
|
|
8199
|
-
// absence — attaching NOTHING means "leave the standing wake alone", which is
|
|
8200
|
-
// exactly what a stand-down must not do. So a `cancel_wake` turn attaches the
|
|
8201
|
-
// discriminated `{ cancel: true }` payload instead.
|
|
8202
|
-
wakeField() {
|
|
8203
|
-
return wakeCommitField(this.deps.wakeState);
|
|
8204
|
-
}
|
|
8205
8240
|
};
|
|
8206
8241
|
|
|
8207
8242
|
// src/turn-execution.ts
|
|
@@ -8292,7 +8327,7 @@ var TurnExecution = class {
|
|
|
8292
8327
|
agentId: payload.agentId,
|
|
8293
8328
|
messageId: payload.messageId
|
|
8294
8329
|
});
|
|
8295
|
-
this.turnId = handleOpts.turnId ??
|
|
8330
|
+
this.turnId = handleOpts.turnId ?? randomUUID2();
|
|
8296
8331
|
}
|
|
8297
8332
|
opts;
|
|
8298
8333
|
supervisor;
|
|
@@ -8312,12 +8347,8 @@ var TurnExecution = class {
|
|
|
8312
8347
|
effectiveCwd;
|
|
8313
8348
|
hookEnv;
|
|
8314
8349
|
turnEnv;
|
|
8315
|
-
sendState;
|
|
8316
8350
|
skipState;
|
|
8317
|
-
askState;
|
|
8318
|
-
wakeState;
|
|
8319
8351
|
replyState;
|
|
8320
|
-
controlOrder;
|
|
8321
8352
|
turnControlServer;
|
|
8322
8353
|
request;
|
|
8323
8354
|
adapter;
|
|
@@ -8487,7 +8518,7 @@ var TurnExecution = class {
|
|
|
8487
8518
|
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
8488
8519
|
body: `${MISSING_SECRET_PREFIX} ${list}`,
|
|
8489
8520
|
kind: "final",
|
|
8490
|
-
turnId:
|
|
8521
|
+
turnId: randomUUID2(),
|
|
8491
8522
|
// CT113: even a turn that fails before it runs answers a message.
|
|
8492
8523
|
parentMessageId: payload.messageId
|
|
8493
8524
|
});
|
|
@@ -8694,19 +8725,12 @@ ${reason}`,
|
|
|
8694
8725
|
}
|
|
8695
8726
|
buildRequest() {
|
|
8696
8727
|
const { payload, workspaceId } = this;
|
|
8697
|
-
const sendState = this.sendState = createSendState();
|
|
8698
8728
|
const skipState = this.skipState = createSkipState();
|
|
8699
|
-
const askState = this.askState = createAskState();
|
|
8700
|
-
const wakeState = this.wakeState = createWakeState();
|
|
8701
8729
|
const replyState = this.replyState = createReplyState();
|
|
8702
|
-
const controlOrder = this.controlOrder = createTurnControlOrder();
|
|
8703
8730
|
const turnControlServer = createTurnControlMcpServer(
|
|
8704
|
-
|
|
8731
|
+
{ api: this.opts.api, workspaceId, turnId: this.turnId },
|
|
8705
8732
|
skipState,
|
|
8706
|
-
|
|
8707
|
-
wakeState,
|
|
8708
|
-
replyState,
|
|
8709
|
-
controlOrder
|
|
8733
|
+
replyState
|
|
8710
8734
|
);
|
|
8711
8735
|
this.request = buildCompanionTurnRequest({
|
|
8712
8736
|
turnContext: this.turnContext,
|
|
@@ -8808,16 +8832,6 @@ ${reason}`,
|
|
|
8808
8832
|
signal: abortController.signal,
|
|
8809
8833
|
log: turnLog,
|
|
8810
8834
|
nextSeq: this.nextSeq,
|
|
8811
|
-
// The committer reads this at commit to carry the addressed send on the
|
|
8812
|
-
// terminal row; the server writes the send itself as a distinct message.
|
|
8813
|
-
sendState: this.sendState,
|
|
8814
|
-
// CT326: likewise the ask payload. CT1281: the server writes the ask as its
|
|
8815
|
-
// own addressed message to the human — which ENQUEUES like any other send —
|
|
8816
|
-
// and creates the `asks` row against that carrier, not against turn speech.
|
|
8817
|
-
askState: this.askState,
|
|
8818
|
-
// CT442: likewise the wake payload — attached to the `final` row so the
|
|
8819
|
-
// server arms the wake schedule atomically with the reply it rode on.
|
|
8820
|
-
wakeState: this.wakeState,
|
|
8821
8835
|
replyState: this.replyState,
|
|
8822
8836
|
// The ledger-derived owed reply, for the runtime's own declaration when
|
|
8823
8837
|
// the agent doesn't call `reply_to` (resolveDeclaredReplyField).
|
|
@@ -8840,41 +8854,8 @@ ${reason}`,
|
|
|
8840
8854
|
payload.agentId,
|
|
8841
8855
|
turnId
|
|
8842
8856
|
);
|
|
8843
|
-
if (intent.ask) {
|
|
8844
|
-
this.askState.targetUserId = intent.ask.targetUserId;
|
|
8845
|
-
if (intent.ask.questions && intent.ask.questions.length > 0) {
|
|
8846
|
-
this.askState.questions = intent.ask.questions;
|
|
8847
|
-
this.askState.question = null;
|
|
8848
|
-
this.askState.headline = null;
|
|
8849
|
-
this.askState.options = null;
|
|
8850
|
-
} else {
|
|
8851
|
-
this.askState.question = intent.ask.question ?? null;
|
|
8852
|
-
this.askState.headline = intent.ask.headline ?? null;
|
|
8853
|
-
this.askState.options = intent.ask.options ?? null;
|
|
8854
|
-
this.askState.questions = null;
|
|
8855
|
-
}
|
|
8856
|
-
}
|
|
8857
|
-
if (intent.wake) {
|
|
8858
|
-
if ("cancel" in intent.wake) {
|
|
8859
|
-
this.wakeState.cancelled = true;
|
|
8860
|
-
this.wakeState.afterSeconds = null;
|
|
8861
|
-
this.wakeState.at = null;
|
|
8862
|
-
this.wakeState.note = null;
|
|
8863
|
-
} else {
|
|
8864
|
-
this.wakeState.cancelled = false;
|
|
8865
|
-
this.wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
|
|
8866
|
-
this.wakeState.at = intent.wake.at ?? null;
|
|
8867
|
-
this.wakeState.note = intent.wake.note;
|
|
8868
|
-
}
|
|
8869
|
-
}
|
|
8870
|
-
if (intent.sendAgentId && intent.sendBody) {
|
|
8871
|
-
this.sendState.agentId = intent.sendAgentId;
|
|
8872
|
-
this.sendState.message = intent.sendBody;
|
|
8873
|
-
this.sendState.order = intent.sendOrder ?? null;
|
|
8874
|
-
}
|
|
8875
8857
|
if (intent.answersMessageId) {
|
|
8876
8858
|
this.replyState.answersMessageId = intent.answersMessageId;
|
|
8877
|
-
this.replyState.order = intent.replyOrder ?? null;
|
|
8878
8859
|
}
|
|
8879
8860
|
if (intent.skipped) {
|
|
8880
8861
|
this.skipState.skipped = true;
|
|
@@ -8883,7 +8864,7 @@ ${reason}`,
|
|
|
8883
8864
|
} catch (err) {
|
|
8884
8865
|
turnLog.warn(
|
|
8885
8866
|
{ err: err instanceof Error ? err.message : String(err) },
|
|
8886
|
-
"dispatcher: turn-control intent fetch failed;
|
|
8867
|
+
"dispatcher: turn-control intent fetch failed; the declared reply / skip for this turn are dropped"
|
|
8887
8868
|
);
|
|
8888
8869
|
}
|
|
8889
8870
|
};
|
|
@@ -9026,15 +9007,13 @@ ${reason}`,
|
|
|
9026
9007
|
{ reason: this.skipState.reason, turnId, ok: o.okResult },
|
|
9027
9008
|
"agent skipped turn (skip_turn)"
|
|
9028
9009
|
);
|
|
9029
|
-
const { wake: skipWake } = wakeCommitField(this.wakeState);
|
|
9030
9010
|
try {
|
|
9031
9011
|
await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
|
|
9032
9012
|
body: SKIPPED_MARKER_BODY,
|
|
9033
9013
|
kind: "skipped",
|
|
9034
9014
|
turnId,
|
|
9035
9015
|
seq: this.nextSeq(),
|
|
9036
|
-
parentMessageId: payload.messageId
|
|
9037
|
-
...skipWake ? { wake: skipWake } : {}
|
|
9016
|
+
parentMessageId: payload.messageId
|
|
9038
9017
|
});
|
|
9039
9018
|
} catch (err) {
|
|
9040
9019
|
turnLog.warn(
|
|
@@ -9052,9 +9031,9 @@ ${reason}`,
|
|
|
9052
9031
|
turnId,
|
|
9053
9032
|
seq: this.nextSeq(),
|
|
9054
9033
|
parentMessageId: payload.messageId,
|
|
9055
|
-
//
|
|
9056
|
-
//
|
|
9057
|
-
//
|
|
9034
|
+
// An explicit `reply_to` survives a wordless turn exactly as it
|
|
9035
|
+
// survives a textual final (the auto-declaration does not — a
|
|
9036
|
+
// marker has no answer to bind).
|
|
9058
9037
|
...committer.turnControlFields("silent")
|
|
9059
9038
|
});
|
|
9060
9039
|
o.silentMarkerEmitted = true;
|
|
@@ -10366,7 +10345,7 @@ var CompanionSupervisor = class {
|
|
|
10366
10345
|
);
|
|
10367
10346
|
}
|
|
10368
10347
|
const resumedTurnId = ev.id ? turnIdForEvent(workspaceId, ev.id) : null;
|
|
10369
|
-
const turnId = resumedTurnId ??
|
|
10348
|
+
const turnId = resumedTurnId ?? randomUUID3();
|
|
10370
10349
|
if (ev.id) {
|
|
10371
10350
|
const liveTurnIds = rememberTurnId(workspaceId, ev.id, turnId);
|
|
10372
10351
|
if (liveTurnIds > TURN_ID_OVERFLOW_WARN) {
|
|
@@ -10774,7 +10753,7 @@ async function createCompanionRuntime(opts = {}) {
|
|
|
10774
10753
|
opencodeServerUrl: cfg.opencode?.serverUrl,
|
|
10775
10754
|
codex: isCodexEnabled(cfg)
|
|
10776
10755
|
});
|
|
10777
|
-
const instanceId =
|
|
10756
|
+
const instanceId = randomUUID4();
|
|
10778
10757
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
10779
10758
|
const pre = readLiveRuntimeState();
|
|
10780
10759
|
if (pre && await verifyRuntime(pre) === "stale") clearRuntimeState();
|