@2kw/ai 6.3.0-dev.98 → 6.4.0-dev.10

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.
@@ -1,10 +1,9 @@
1
1
  import { Command } from "commander";
2
- import { resolveAuthHeader } from "../lib/client.js";
3
- import { resolveConfig, isJsonOutput } from "../lib/config.js";
2
+ import { getClient } from "../lib/client.js";
3
+ import { isJsonOutput } from "../lib/config.js";
4
4
  import { handleError } from "../lib/errors.js";
5
5
  import { formatDetail, withSpinner } from "../lib/output.js";
6
6
  import { fileToBlob } from "../lib/multipart.js";
7
- import { BackboneApiError } from "../lib/errors.js";
8
7
  export function makeTranscribeCommand() {
9
8
  const cmd = new Command("transcribe")
10
9
  .description("Transcribe an audio file")
@@ -17,8 +16,6 @@ export function makeTranscribeCommand() {
17
16
  .action(async (file, opts, command) => {
18
17
  const json = isJsonOutput(command);
19
18
  try {
20
- const config = resolveConfig(command);
21
- const authHeader = await resolveAuthHeader(config);
22
19
  const { blob, filename } = fileToBlob(file);
23
20
  const formData = new FormData();
24
21
  formData.append("file", blob, filename);
@@ -31,30 +28,15 @@ export function makeTranscribeCommand() {
31
28
  formData.append("response_format", opts.format);
32
29
  if (opts.temperature)
33
30
  formData.append("temperature", opts.temperature);
34
- const baseUrl = config.baseUrl.replace(/\/+$/, "");
35
- const url = `${baseUrl}/v1/audio/transcriptions`;
31
+ const client = getClient(command);
36
32
  const data = await withSpinner("Transcribing...", async () => {
37
- const res = await fetch(url, {
38
- method: "POST",
39
- headers: { Authorization: authHeader },
40
- body: formData,
33
+ // The generated type describes "file" as a binary string, which FormData cannot match;
34
+ // the placeholder satisfies it and bodySerializer sends the real parts.
35
+ const result = await client.POST("/v1/audio/transcriptions", {
36
+ body: { file: "", model: opts.model },
37
+ bodySerializer: () => formData,
41
38
  });
42
- if (!res.ok) {
43
- let body;
44
- try {
45
- body = await res.json();
46
- }
47
- catch {
48
- body = {
49
- error: res.statusText,
50
- message: `HTTP ${res.status}: ${res.statusText}`,
51
- status: res.status,
52
- timestamp: new Date().toISOString(),
53
- };
54
- }
55
- throw new BackboneApiError(body);
56
- }
57
- return res.json();
39
+ return result.data;
58
40
  });
59
41
  formatDetail(data, command);
60
42
  }
@@ -22,21 +22,47 @@ export interface PromptAnswer {
22
22
  reason?: string;
23
23
  remember?: boolean;
24
24
  }
25
+ /** One relayed tool call answered by `--output` or `--fail` (#671 D2, D3). */
26
+ export interface ToolAnswer {
27
+ callId: string;
28
+ /** Sent verbatim as the item's `output` string. */
29
+ output: string;
30
+ failed: boolean;
31
+ }
32
+ export interface ToolAnswerIo {
33
+ readFile: (path: string) => string;
34
+ readStdin: () => string;
35
+ }
36
+ /**
37
+ * `--output` and `--fail` values as `<callId>=<value>`, split on the first `=`. `@<path>` reads a file and
38
+ * `@-` reads stdin, both verbatim. Only syntax is checked here: without stored state the CLI cannot list the
39
+ * calls a response released, so completeness is the server's check (#671 D4). Every check runs before any read.
40
+ */
41
+ export declare function parseToolAnswers(outputs?: string[], fails?: string[], io?: ToolAnswerIo): ToolAnswer[];
25
42
  export declare function fetchPendingApprovals(client: ApiClient, agentId: string, responseId: string): Promise<ApprovalRow[]>;
26
43
  /**
27
44
  * The backend refuses a continuation that leaves any pending approval of the paused
28
45
  * response undecided (400 incomplete_tool_outputs), so the whole set is checked here first.
29
46
  */
30
47
  export declare function planDecisions(pending: ApprovalRow[], opts: DecideOptions, responseId: string): Decision[];
48
+ /**
49
+ * The decisions a `decide` call sends beside its tool answers (#671 D5). A pure relay pause has no pending
50
+ * approval, so the approval flags are optional there: `--approve-all` / `--reject-all` add nothing (P1) and
51
+ * only explicit ids are refused. A mixed pause needs its approvals decided in the same call.
52
+ */
53
+ export declare function planContinuation(pending: ApprovalRow[], opts: DecideOptions, toolAnswers: ToolAnswer[], responseId: string): Decision[];
31
54
  export declare function decisionsFromAnswers(pending: ApprovalRow[], answers: Map<string, PromptAnswer>): Decision[];
32
55
  /** Same item shape as n8n Decide Approval (#660). */
33
56
  export declare function toApprovalItems(decisions: Decision[]): Record<string, string>[];
57
+ /** `failed: true` is Backbone's extension (#480): the tool's span ends ERROR. */
58
+ export declare function toToolOutputItems(answers: ToolAnswer[]): Record<string, string | boolean>[];
34
59
  /**
35
60
  * Continues on `agent/<agentId>[@<label>][#<model>]` with the label and model override the run used: the
36
61
  * server resolves the continuation's tools, instructions and model from this reference, so both must match
37
62
  * the run's or the continuation drops to another version or the agent's default model.
38
63
  * Never the echoed `name@<versionNumber>` (it would 404 as a label).
39
64
  * With `mode`, the `backbone:mode` item follows the decisions (#656 D7); without it, nothing is added (D2).
65
+ * Tool answers go first, in flag order (#671 D6): the server replays accepted outputs ahead of other items.
40
66
  */
41
- export declare function continueWithDecisions(client: ApiClient, agentId: string, responseId: string, decisions: Decision[], label?: string, model?: string, mode?: ConversationMode): Promise<ResponsesResult>;
67
+ export declare function continueWithDecisions(client: ApiClient, agentId: string, responseId: string, decisions: Decision[], label?: string, model?: string, mode?: ConversationMode, toolAnswers?: ToolAnswer[]): Promise<ResponsesResult>;
42
68
  //# sourceMappingURL=agent-decide.d.ts.map
@@ -1,6 +1,69 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { modeItem } from "./agent-run.js";
2
3
  import { CliUsageError } from "./errors.js";
3
4
  import { paginationParams } from "./pagination.js";
5
+ const defaultIo = {
6
+ readFile: (path) => readFileSync(path, "utf-8"),
7
+ // Not readStdinInput: that one trims, and a tool result is sent as read (P5).
8
+ readStdin: () => readFileSync(0, "utf-8"),
9
+ };
10
+ /**
11
+ * `--output` and `--fail` values as `<callId>=<value>`, split on the first `=`. `@<path>` reads a file and
12
+ * `@-` reads stdin, both verbatim. Only syntax is checked here: without stored state the CLI cannot list the
13
+ * calls a response released, so completeness is the server's check (#671 D4). Every check runs before any read.
14
+ */
15
+ export function parseToolAnswers(outputs = [], fails = [], io = defaultIo) {
16
+ const entries = [
17
+ ...outputs.map((raw) => ({ raw, flag: "--output", noun: "value", failed: false })),
18
+ ...fails.map((raw) => ({ raw, flag: "--fail", noun: "message", failed: true })),
19
+ ].map(({ raw, flag, noun, failed }) => {
20
+ const eq = raw.indexOf("=");
21
+ if (eq < 0)
22
+ throw new CliUsageError(`Invalid ${flag} '${raw}': use <callId>=<${noun}>.`);
23
+ const callId = raw.slice(0, eq);
24
+ if (!callId)
25
+ throw new CliUsageError(`Invalid ${flag} '${raw}': the call id is empty.`);
26
+ return { callId, value: raw.slice(eq + 1), failed };
27
+ });
28
+ const seen = new Map();
29
+ for (const e of entries) {
30
+ const before = seen.get(e.callId);
31
+ if (before === undefined) {
32
+ seen.set(e.callId, e.failed);
33
+ }
34
+ else if (before === e.failed) {
35
+ throw new CliUsageError(`Tool call ${e.callId} is answered more than once.`);
36
+ }
37
+ else {
38
+ throw new CliUsageError(`Tool call ${e.callId} is answered by both --output and --fail.`);
39
+ }
40
+ }
41
+ if (entries.filter((e) => e.value === "@-").length > 1) {
42
+ throw new CliUsageError("Only one --output or --fail value can read stdin (@-).");
43
+ }
44
+ return entries.map(({ callId, value, failed }) => ({ callId, output: readAnswerValue(value, io), failed }));
45
+ }
46
+ function readAnswerValue(value, io) {
47
+ if (value === "@-") {
48
+ try {
49
+ return io.readStdin();
50
+ }
51
+ catch (err) {
52
+ // A closed or non-blocking stdin (EAGAIN, EOF) is a usage error like an unreadable file.
53
+ throw new CliUsageError(`stdin: cannot read (${err.code ?? err.message})`);
54
+ }
55
+ }
56
+ if (!value.startsWith("@"))
57
+ return value;
58
+ const path = value.slice(1);
59
+ try {
60
+ return io.readFile(path);
61
+ }
62
+ catch (err) {
63
+ const code = err.code;
64
+ throw new CliUsageError(`${path}: cannot read file (${code ?? err.message})`);
65
+ }
66
+ }
4
67
  const MAX_PAGES = 50;
5
68
  export async function fetchPendingApprovals(client, agentId, responseId) {
6
69
  const rows = [];
@@ -41,7 +104,8 @@ export function planDecisions(pending, opts, responseId) {
41
104
  throw new CliUsageError("Use either --approve-all / --reject-all or explicit --approve / --reject ids, not both.");
42
105
  }
43
106
  if (!usesAll && approve.length === 0 && reject.length === 0) {
44
- throw new CliUsageError("Choose a decision: --approve <ids...>, --reject <ids...>, --approve-all or --reject-all.");
107
+ throw new CliUsageError("Choose a decision: --approve <ids...>, --reject <ids...>, --approve-all or --reject-all " +
108
+ "(and answer relayed tool calls with --output / --fail).");
45
109
  }
46
110
  const choice = new Map();
47
111
  if (usesAll) {
@@ -84,6 +148,26 @@ export function planDecisions(pending, opts, responseId) {
84
148
  };
85
149
  });
86
150
  }
151
+ /**
152
+ * The decisions a `decide` call sends beside its tool answers (#671 D5). A pure relay pause has no pending
153
+ * approval, so the approval flags are optional there: `--approve-all` / `--reject-all` add nothing (P1) and
154
+ * only explicit ids are refused. A mixed pause needs its approvals decided in the same call.
155
+ */
156
+ export function planContinuation(pending, opts, toolAnswers, responseId) {
157
+ if (toolAnswers.length === 0)
158
+ return planDecisions(pending, opts, responseId);
159
+ const ids = [...(opts.approve ?? []), ...(opts.reject ?? [])];
160
+ if (pending.length === 0) {
161
+ if (ids.length)
162
+ throw new CliUsageError(`Not pending on ${responseId}: ${ids.join(", ")}`);
163
+ return [];
164
+ }
165
+ if (!opts.approveAll && !opts.rejectAll && ids.length === 0) {
166
+ throw new CliUsageError(`${responseId} also waits for approvals: every relayed tool call and every pending approval must be answered ` +
167
+ `in one call. Undecided: ${pending.map((r) => r.id).join(", ")} (add --approve-all, --reject-all or the ids).`);
168
+ }
169
+ return planDecisions(pending, opts, responseId);
170
+ }
87
171
  export function decisionsFromAnswers(pending, answers) {
88
172
  return pending.map((r) => {
89
173
  const answer = answers.get(String(r.id));
@@ -109,20 +193,30 @@ export function toApprovalItems(decisions) {
109
193
  ...(d.remember ? { remember: d.remember } : {}),
110
194
  }));
111
195
  }
196
+ /** `failed: true` is Backbone's extension (#480): the tool's span ends ERROR. */
197
+ export function toToolOutputItems(answers) {
198
+ return answers.map((a) => ({
199
+ type: "function_call_output",
200
+ call_id: a.callId,
201
+ output: a.output,
202
+ ...(a.failed ? { failed: true } : {}),
203
+ }));
204
+ }
112
205
  /**
113
206
  * Continues on `agent/<agentId>[@<label>][#<model>]` with the label and model override the run used: the
114
207
  * server resolves the continuation's tools, instructions and model from this reference, so both must match
115
208
  * the run's or the continuation drops to another version or the agent's default model.
116
209
  * Never the echoed `name@<versionNumber>` (it would 404 as a label).
117
210
  * With `mode`, the `backbone:mode` item follows the decisions (#656 D7); without it, nothing is added (D2).
211
+ * Tool answers go first, in flag order (#671 D6): the server replays accepted outputs ahead of other items.
118
212
  */
119
- export async function continueWithDecisions(client, agentId, responseId, decisions, label, model, mode) {
120
- const approvals = toApprovalItems(decisions);
213
+ export async function continueWithDecisions(client, agentId, responseId, decisions, label, model, mode, toolAnswers = []) {
214
+ const items = [...toToolOutputItems(toolAnswers), ...toApprovalItems(decisions)];
121
215
  const body = {
122
216
  model: `agent/${agentId}${label ? `@${label}` : ""}${model ? `#${model}` : ""}`,
123
217
  previous_response_id: responseId,
124
218
  stream: false,
125
- input: mode ? [...approvals, modeItem(mode)] : approvals,
219
+ input: mode ? [...items, modeItem(mode)] : items,
126
220
  };
127
221
  // The generated body type does not model backbone: extension items, hence the cast.
128
222
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -28,7 +28,8 @@ export interface PendingToolCall {
28
28
  /**
29
29
  * A connector the run waits for the user to connect, allow or reconnect in chat.2kw.ai: an open
30
30
  * `backbone:connector_auth_request` (#807 R12). The caller never answers the connect call; the
31
- * continuation re-checks access itself.
31
+ * continuation re-checks access itself. Decoded by `connect-pause.ts` (#1086), whose `id` and
32
+ * `callId` the envelope does not publish.
32
33
  */
33
34
  export interface PendingConnection {
34
35
  serverLabel: string;
@@ -115,6 +116,9 @@ export declare function parseAgentModel(model?: string): {
115
116
  * clipboard. Newlines and tabs stay. JSON output is never passed through this.
116
117
  */
117
118
  export declare function stripControl(s: string): string;
119
+ export declare function hasInvisible(s: string): boolean;
120
+ /** Replaces each hidden code point with a visible `<U+XXXX>`, so approving a diff means having seen it. */
121
+ export declare function revealInvisible(s: string): string;
118
122
  export declare function shellQuote(value: string): string;
119
123
  /** "Connect a (h), allow the agent to use b (h) and reconnect c (h)". */
120
124
  export declare function connectionsSentence(connections: PendingConnection[]): string;
@@ -1,6 +1,8 @@
1
1
  import chalk from "chalk";
2
2
  import { DEFAULT_CHAT_URL } from "./config.js";
3
+ import { connectCallIds, connectionsPhrase, pendingConnectionsOf } from "./connect-pause.js";
3
4
  import { CliUsageError } from "./errors.js";
5
+ import { previewIsIncomplete, previewLines } from "./skills-apply-preview.js";
4
6
  export const CONVERSATION_MODES = ["plan", "ask", "auto"];
5
7
  /** `--mode` help text, in the words of the embedded chat's mode chip (#655). */
6
8
  export const MODE_OPTION_HELP = "Conversation mode: plan (read-only), ask (no automatic approver; calls that need approval wait for you), " +
@@ -87,61 +89,91 @@ const CONTROL_CHARS = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
87
89
  export function stripControl(s) {
88
90
  return s.replace(CONTROL_CHARS, "");
89
91
  }
90
- export function shellQuote(value) {
91
- if (/^[A-Za-z0-9._@/:-]+$/.test(value))
92
- return value;
93
- return `'${value.replace(/'/g, `'\\''`)}'`;
94
- }
95
- /** An open connector consent request; a continuation's projection carries another status. */
96
- function pendingConnectionsOf(output) {
97
- return output
98
- .filter((i) => i.type === "backbone:connector_auth_request" && (i.status === undefined || i.status === "in_progress"))
99
- .map((i) => ({
100
- serverLabel: String(i.server_label),
101
- host: String(i.host),
102
- reason: String(i.reason),
103
- ...(Array.isArray(i.destinations) && i.destinations.length > 0
104
- ? { destinations: i.destinations.map((d) => String(d)) }
105
- : {}),
106
- }));
107
- }
108
92
  /**
109
- * The call ids a connect pause withholds from the caller: the call each request names, and every
110
- * `mcp__<label>__connect` call for a pending connector (the model may call it more than once; one
111
- * request stands for all of them).
93
+ * Code points that print as nothing, or reorder what surrounds them, yet reach the model: Unicode
94
+ * tags, zero-width characters, directional marks, embeddings, overrides and isolates, the word
95
+ * joiner and the byte-order mark (#1027). As numbers, not an escaped regex, so no tool can fold
96
+ * them into the source. Kept in step with `surface/src/core/skills-apply-preview.ts`.
112
97
  */
113
- function connectCallIds(output, connections) {
114
- const connectNames = new Set(connections.map((c) => `mcp__${c.serverLabel}__connect`));
115
- const ids = new Set();
116
- for (const item of output) {
117
- if (item.type === "backbone:connector_auth_request" && (item.status === undefined || item.status === "in_progress")) {
118
- ids.add(String(item.call_id));
119
- }
120
- else if (item.type === "function_call" && connectNames.has(String(item.name))) {
121
- ids.add(String(item.call_id));
122
- }
98
+ const INVISIBLE_RANGES = [
99
+ [0xe0000, 0xe007f],
100
+ [0x200b, 0x200f],
101
+ [0x202a, 0x202e],
102
+ [0x2060, 0x2060],
103
+ [0x2066, 0x2069],
104
+ [0x061c, 0x061c],
105
+ [0xfeff, 0xfeff],
106
+ ];
107
+ function isInvisible(codePoint) {
108
+ return INVISIBLE_RANGES.some(([low, high]) => codePoint >= low && codePoint <= high);
109
+ }
110
+ export function hasInvisible(s) {
111
+ for (const char of s)
112
+ if (isInvisible(char.codePointAt(0)))
113
+ return true;
114
+ return false;
115
+ }
116
+ /** Replaces each hidden code point with a visible `<U+XXXX>`, so approving a diff means having seen it. */
117
+ export function revealInvisible(s) {
118
+ let out = "";
119
+ for (const char of s) {
120
+ const codePoint = char.codePointAt(0);
121
+ out += isInvisible(codePoint) ? `<U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}>` : char;
123
122
  }
124
- return ids;
123
+ return out;
125
124
  }
126
- function connectionPhrase(c) {
127
- const target = `${c.serverLabel} (${c.host})`;
128
- if (c.reason === "allow")
129
- return `allow the agent to use ${target}`;
130
- if (c.reason === "reconnect")
131
- return `reconnect ${target}`;
132
- return `connect ${target}`;
125
+ export function shellQuote(value) {
126
+ if (/^[A-Za-z0-9._@/:-]+$/.test(value))
127
+ return value;
128
+ return `'${value.replace(/'/g, `'\\''`)}'`;
133
129
  }
134
130
  /** "Connect a (h), allow the agent to use b (h) and reconnect c (h)". */
135
131
  export function connectionsSentence(connections) {
136
- const phrases = connections.map(connectionPhrase);
137
- const joined = phrases.length > 1 ? `${phrases.slice(0, -1).join(", ")} and ${phrases.at(-1)}` : phrases[0] ?? "";
132
+ const joined = connectionsPhrase(connections);
138
133
  return joined.charAt(0).toUpperCase() + joined.slice(1);
139
134
  }
140
135
  /** Joins a connect pause's instruction to the command that continues it; {@link printRunText} breaks the line there. */
141
136
  const THEN_RUN = ", then run: ";
137
+ /**
138
+ * The `decide` command that answers a relay pause (#671 D7): one `--output <callId>=@<file>` per pending call,
139
+ * plus `--approve-all` when approvals wait too. The call id is chosen by the model, so the suggested file name
140
+ * keeps only `[A-Za-z0-9_-]` (P2) and never repeats: it can be neither `../…` nor an absolute path.
141
+ */
142
+ function relayDecideCommand(ref, responseId, calls, withApprovals) {
143
+ const used = new Set();
144
+ const outputs = calls.map((c) => {
145
+ const safe = c.callId.replace(/[^A-Za-z0-9_-]/g, "_");
146
+ let file = safe;
147
+ // Compared without case: on Windows and macOS `callA.out` and `calla.out` are one file.
148
+ for (let n = 2; used.has(file.toLowerCase()); n++)
149
+ file = `${safe}-${n}`;
150
+ used.add(file.toLowerCase());
151
+ return ` --output ${shellQuote(`${c.callId}=@${file}.out`)}`;
152
+ });
153
+ return (`2kw agents decide ${shellQuote(ref)} --response ${shellQuote(responseId)}${outputs.join("")}` +
154
+ (withApprovals ? " --approve-all" : ""));
155
+ }
156
+ /**
157
+ * A connector tool's open approval, decoded from the standard `mcp_approval_request` item (#949). The item
158
+ * is the MCP twin of `backbone:approval_request` (#805): it names the server and the tool, carries no call
159
+ * id, policy class or status, and exists only while the decision is open, so its id doubles as the call id
160
+ * and the tool is named as the approval row stores it, `mcp__<server_label>__<tool>`.
161
+ */
162
+ function connectorApproval(i) {
163
+ return {
164
+ approvalId: String(i.id),
165
+ callId: String(i.id),
166
+ tool: `mcp__${String(i.server_label)}__${String(i.name)}`,
167
+ arguments: parseArguments(i.arguments),
168
+ policyClass: "unknown",
169
+ reason: null,
170
+ preview: null,
171
+ };
172
+ }
142
173
  /**
143
174
  * Output item types the envelope decodes, or that never hold a run: text, reasoning and finished
144
- * connector calls. A pause on anything else (a connector approval's `mcp_approval_request`) is named.
175
+ * connector calls. A pause on anything else is named.
176
+ * `backbone:tool_image` is an image a finished `ViewImage` call attached (#1244): it never holds a run.
145
177
  */
146
178
  const DECODED_ITEM_TYPES = new Set([
147
179
  "message",
@@ -150,6 +182,8 @@ const DECODED_ITEM_TYPES = new Set([
150
182
  "function_call_output",
151
183
  "backbone:approval_request",
152
184
  "backbone:connector_auth_request",
185
+ "backbone:tool_image",
186
+ "mcp_approval_request",
153
187
  "mcp_call",
154
188
  "mcp_list_tools",
155
189
  ]);
@@ -173,20 +207,30 @@ export function buildRunEnvelope(result, agentRef, chatUrl = DEFAULT_CHAT_URL) {
173
207
  String(i.call_id),
174
208
  i.status === "incomplete" ? "incomplete" : "completed",
175
209
  ]));
176
- const pendingApprovals = output
177
- // Only an open request is pending; judge- or grant-decided requests (#634, #629) carry another status.
178
- .filter((i) => i.type === "backbone:approval_request" && (i.status === undefined || i.status === "in_progress"))
179
- .map((i) => ({
180
- approvalId: String(i.id),
181
- callId: String(i.call_id),
182
- tool: String(i.tool),
183
- arguments: parseArguments(i.arguments),
184
- policyClass: String(i.policy_class),
185
- reason: typeof i.reason === "string" && i.reason ? i.reason : null,
186
- preview: i.preview && typeof i.preview === "object" && !Array.isArray(i.preview) ? i.preview : null,
210
+ const pendingApprovals = [
211
+ ...output
212
+ // Only an open request is pending; judge- or grant-decided requests (#634, #629) carry another status.
213
+ .filter((i) => i.type === "backbone:approval_request" && (i.status === undefined || i.status === "in_progress"))
214
+ .map((i) => ({
215
+ approvalId: String(i.id),
216
+ callId: String(i.call_id),
217
+ tool: String(i.tool),
218
+ arguments: parseArguments(i.arguments),
219
+ policyClass: String(i.policy_class),
220
+ reason: typeof i.reason === "string" && i.reason ? i.reason : null,
221
+ preview: i.preview && typeof i.preview === "object" && !Array.isArray(i.preview) ? i.preview : null,
222
+ })),
223
+ ...output.filter((i) => i.type === "mcp_approval_request").map(connectorApproval),
224
+ ];
225
+ // The one decoding rule (#1086): only an open request with its id, call id, label and host.
226
+ const connectRequests = pendingConnectionsOf(output);
227
+ const pendingConnections = connectRequests.map(({ serverLabel, host, reason, destinations }) => ({
228
+ serverLabel,
229
+ host,
230
+ reason,
231
+ ...(destinations ? { destinations } : {}),
187
232
  }));
188
- const pendingConnections = pendingConnectionsOf(output);
189
- const withheldCallIds = new Set([...pendingApprovals.map((a) => a.callId), ...connectCallIds(output, pendingConnections)]);
233
+ const withheldCallIds = new Set([...pendingApprovals.map((a) => a.callId), ...connectCallIds(output, connectRequests)]);
190
234
  const toolCalls = [];
191
235
  const pendingToolCalls = [];
192
236
  for (const item of output) {
@@ -225,8 +269,23 @@ export function buildRunEnvelope(result, agentRef, chatUrl = DEFAULT_CHAT_URL) {
225
269
  if (status === "requires_approval" && ref && responseId) {
226
270
  next = `2kw agents decide ${shellQuote(ref)} --response ${responseId} --approve-all`;
227
271
  }
228
- else if (status === "requires_tool_output" && pendingConnections.length > 0 && pendingToolCalls.length === 0 && responseId) {
229
- // Not with a relayed call beside it: a continuation without that call's output is refused.
272
+ else if (status === "requires_tool_output" && pendingToolCalls.length > 0) {
273
+ // The engine answers the connect call itself on the continuation, so connecting first is enough.
274
+ if (ref && responseId) {
275
+ const decide = relayDecideCommand(ref, responseId, pendingToolCalls, pendingApprovals.length > 0);
276
+ next = pendingConnections.length > 0
277
+ ? `${connectionsSentence(pendingConnections)} in ${chatUrl}/connectors${THEN_RUN}${decide}`
278
+ : decide;
279
+ }
280
+ }
281
+ else if (status === "requires_tool_output" && pendingConnections.length > 0 && pendingApprovals.length > 0 && ref && responseId) {
282
+ // A continuation must decide every pending approval (400 incomplete_tool_outputs otherwise), so
283
+ // `--continue` would fail; decide answers them and the engine re-checks access itself (#949 review).
284
+ next =
285
+ `${connectionsSentence(pendingConnections)} in ${chatUrl}/connectors${THEN_RUN}` +
286
+ `2kw agents decide ${shellQuote(ref)} --response ${shellQuote(responseId)} --approve-all`;
287
+ }
288
+ else if (status === "requires_tool_output" && pendingConnections.length > 0 && responseId) {
230
289
  // `agents run --continue` needs no input: the continuation re-checks access itself.
231
290
  const resume = ref
232
291
  ? `${THEN_RUN}2kw agents run ${shellQuote(ref)} --continue ${shellQuote(responseId)}`
@@ -272,19 +331,39 @@ export function printRunText(env) {
272
331
  console.log(chalk.dim(`\n── ${s(head.join(" · "))}`));
273
332
  if (env.toolCalls.length)
274
333
  console.log(chalk.dim(` tools: ${env.toolCalls.map((c) => `${s(c.tool)} ${c.status === "incomplete" ? "✗" : "✓"}`).join(" ")}`));
275
- if (env.status === "requires_approval") {
276
- console.log(chalk.yellow("\nPaused for approval:"));
277
- env.pendingApprovals.forEach((a, i) => {
278
- console.log(` ${i + 1}. ${s(a.tool)} [${s(a.policyClass)}] ${s(a.approvalId)}`);
334
+ const printApprovals = () => env.pendingApprovals.forEach((a, i) => {
335
+ console.log(` ${i + 1}. ${s(a.tool)} [${s(a.policyClass)}] ${s(a.approvalId)}`);
336
+ // A SkillsApply change reads as its diff (#1028), like the interactive prompt; an incomplete
337
+ // preview keeps the arguments, the one place a member can read what would persist.
338
+ if (a.preview)
339
+ for (const line of previewLines(a.preview))
340
+ console.log(chalk.dim(` ${s(line)}`));
341
+ if (!a.preview || previewIsIncomplete(a.preview)) {
279
342
  // JSON.stringify escapes C0 controls but leaves DEL and C1 raw.
280
343
  console.log(chalk.dim(` ${s(JSON.stringify(a.arguments))}`));
281
- if (a.reason)
282
- console.log(chalk.dim(` reason: ${s(a.reason)}`));
283
- });
344
+ }
345
+ if (a.reason)
346
+ console.log(chalk.dim(` reason: ${s(a.reason)}`));
347
+ });
348
+ if (env.status === "requires_approval") {
349
+ console.log(chalk.yellow("\nPaused for approval:"));
350
+ printApprovals();
284
351
  if (env.next)
285
352
  console.log(`\nDecide with:\n ${s(env.next)}`);
286
353
  }
287
354
  else if (env.status === "requires_tool_output") {
355
+ if (env.pendingToolCalls.length) {
356
+ console.log(chalk.yellow("\nPaused: the agent waits for client-side tool output:"));
357
+ env.pendingToolCalls.forEach((c) => {
358
+ console.log(` - ${s(c.tool)} (${s(c.callId)})`);
359
+ console.log(chalk.dim(` ${s(JSON.stringify(c.arguments))}`));
360
+ });
361
+ // The printed command carries --approve-all, so show what it approves (#671 P3).
362
+ if (env.pendingApprovals.length) {
363
+ console.log(chalk.yellow("\nIt also waits for approval:"));
364
+ printApprovals();
365
+ }
366
+ }
288
367
  if (env.pendingConnections.length) {
289
368
  console.log(chalk.yellow("\nPaused: the agent needs you to connect in chat:"));
290
369
  env.pendingConnections.forEach((c) => {
@@ -296,9 +375,8 @@ export function printRunText(env) {
296
375
  if (env.next)
297
376
  console.log(`\n${s(env.next.replace(THEN_RUN, ", then run:\n "))}`);
298
377
  }
299
- if (env.pendingToolCalls.length) {
300
- console.log(chalk.yellow("\nPaused: the agent needs client-side tool output this CLI cannot provide:"));
301
- env.pendingToolCalls.forEach((c) => console.log(` - ${s(c.tool)} (${s(c.callId)})`));
378
+ else if (env.pendingToolCalls.length && env.next) {
379
+ console.log(`\nAnswer with:\n ${s(env.next)}`);
302
380
  }
303
381
  if (!env.pendingConnections.length && !env.pendingToolCalls.length) {
304
382
  console.log(chalk.yellow("\nPaused on something this CLI cannot show:"));
@@ -1,6 +1,6 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import chalk from "chalk";
3
- import { stripControl } from "./agent-run.js";
3
+ import { hasInvisible, revealInvisible, stripControl } from "./agent-run.js";
4
4
  import { REMEMBER_NOTE, previewIsIncomplete, previewLines } from "./skills-apply-preview.js";
5
5
  /** Raised when the prompt's input closes (EOF, Ctrl+C, Ctrl+D) before an answer arrives. */
6
6
  export class PromptAbortedError extends Error {
@@ -50,9 +50,11 @@ export function readlineAsk(input = process.stdin, output = process.stderr) {
50
50
  }
51
51
  export async function promptApprovals(pending, ask, write = (line) => console.error(line)) {
52
52
  const answers = new Map();
53
- const s = stripControl;
53
+ const s = (value) => revealInvisible(stripControl(value));
54
54
  for (const [index, a] of pending.entries()) {
55
- const destructive = a.policyClass.toLowerCase() === "destructive";
55
+ // A connector approval carries no policy class (#949): "unknown" may be destructive, so it is never
56
+ // offered a standing grant here; the server drops one for a destructive row anyway.
57
+ const destructive = ["destructive", "unknown"].includes(a.policyClass.toLowerCase());
56
58
  write(chalk.yellow(`\nApproval ${index + 1}/${pending.length}: ${s(a.tool)} [${s(a.policyClass)}]`));
57
59
  // JSON.stringify escapes C0 controls but leaves DEL and C1 raw.
58
60
  const request = () => write(chalk.dim(s(JSON.stringify(a.arguments, null, 2))));
@@ -60,7 +62,12 @@ export async function promptApprovals(pending, ask, write = (line) => console.er
60
62
  // A SkillsApply change reads as its diff (#782, spec §10). When the preview leaves changes
61
63
  // out, the full arguments follow it: this prompt never runs with --json, so it is the only
62
64
  // place the member can read what would persist (spec D7).
63
- for (const line of previewLines(a.preview))
65
+ const lines = previewLines(a.preview);
66
+ // Hidden characters are shown as <U+XXXX> below; the warning says why (#1027, spec D7).
67
+ if (lines.some(hasInvisible)) {
68
+ write(chalk.yellow("Hidden characters found: shown as <U+XXXX>. A model reads them; you would not see them. Check each marked line before you approve."));
69
+ }
70
+ for (const line of lines)
64
71
  write(colourPreviewLine(s(line)));
65
72
  if (previewIsIncomplete(a.preview)) {
66
73
  write(chalk.dim("Full request (the preview leaves changes out):"));
@@ -52,12 +52,12 @@ export declare const DEFAULT_BASE_URL: string;
52
52
  */
53
53
  export declare const DEFAULT_AUTH_URL: string;
54
54
  export declare function defaultAuthUrlFor(baseUrl: string): string;
55
- /** Fallback chat.2kw.ai origin when the API host is not one we recognise. */
56
- export declare const DEFAULT_CHAT_URL = "https://chat.2kw.ai";
55
+ export { DEFAULT_CHAT_URL } from "./connect-pause.js";
57
56
  /**
58
57
  * The chat web host that belongs to an API base URL, where a member connects a connector
59
58
  * (`<chat>/connectors`). `AI_2KW_CHAT_URL` overrides the mapping, for deployments the map
60
- * does not know; a trailing slash is dropped so callers can append a path.
59
+ * does not know; a trailing slash is dropped so callers can append a path. The mapping itself
60
+ * is `connect-pause.ts`'s {@link chatOriginFor}, shared with the MCP server and n8n (#1086).
61
61
  */
62
62
  export declare function chatUrlFor(baseUrl: string, env?: NodeJS.ProcessEnv): string;
63
63
  declare const store: Conf<BackboneConfigStore>;