@2kw/ai 6.4.0-dev.7 → 7.0.0-dev.2

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.
@@ -221,6 +221,25 @@ export declare const agentSchema: {
221
221
  };
222
222
  };
223
223
  };
224
+ readonly bashTool: {
225
+ readonly type: "object";
226
+ readonly additionalProperties: false;
227
+ readonly required: readonly ["type"];
228
+ readonly properties: {
229
+ readonly type: {
230
+ readonly const: "backbone.bash";
231
+ };
232
+ readonly approval: {
233
+ readonly enum: readonly ["sandbox", "pause"];
234
+ };
235
+ readonly outputs: {
236
+ readonly enum: readonly ["immediate", "held"];
237
+ };
238
+ readonly annotations: {
239
+ readonly $ref: "#/$defs/annotations";
240
+ };
241
+ };
242
+ };
224
243
  readonly functionTool: {
225
244
  readonly type: "object";
226
245
  readonly additionalProperties: false;
@@ -335,6 +354,18 @@ export declare const agentSchema: {
335
354
  readonly then: {
336
355
  readonly $ref: "#/$defs/skillTool";
337
356
  };
357
+ }, {
358
+ readonly if: {
359
+ required: string[];
360
+ properties: {
361
+ type: {
362
+ const: string;
363
+ };
364
+ };
365
+ };
366
+ readonly then: {
367
+ readonly $ref: "#/$defs/bashTool";
368
+ };
338
369
  }, {
339
370
  readonly if: {
340
371
  required: string[];
@@ -35,6 +35,7 @@ const BUILTIN_TYPES = [
35
35
  "backbone.document_convert",
36
36
  "backbone.todo",
37
37
  "backbone.skill",
38
+ "backbone.bash",
38
39
  "function",
39
40
  ];
40
41
  const typeIs = (t) => ({ required: ["type"], properties: { type: { const: t } } });
@@ -161,6 +162,19 @@ export const agentSchema = {
161
162
  skills: { type: "array", minItems: 1, maxItems: 20, uniqueItems: true, items: { type: "string", pattern: SKILL_REF } },
162
163
  },
163
164
  },
165
+ // The envelope BashToolEntryValidator accepts: no name, no requiresConfirmation, and none of
166
+ // the egress of the withdrawn per-agent network design (#925).
167
+ bashTool: {
168
+ type: "object",
169
+ additionalProperties: false,
170
+ required: ["type"],
171
+ properties: {
172
+ type: { const: "backbone.bash" },
173
+ approval: { enum: ["sandbox", "pause"] },
174
+ outputs: { enum: ["immediate", "held"] },
175
+ annotations: { $ref: "#/$defs/annotations" },
176
+ },
177
+ },
164
178
  functionTool: {
165
179
  type: "object",
166
180
  additionalProperties: false,
@@ -194,6 +208,7 @@ export const agentSchema = {
194
208
  { if: typeIs("backbone.document_convert"), then: { $ref: "#/$defs/documentConvertTool" } },
195
209
  { if: typeIs("backbone.todo"), then: { $ref: "#/$defs/todoTool" } },
196
210
  { if: typeIs("backbone.skill"), then: { $ref: "#/$defs/skillTool" } },
211
+ { if: typeIs("backbone.bash"), then: { $ref: "#/$defs/bashTool" } },
197
212
  { if: typeIs("function"), then: { $ref: "#/$defs/functionTool" } },
198
213
  {
199
214
  // The validator reports these with the reason from NON_AUTHORABLE_TOOL_TYPES; this keeps the published schema honest.
@@ -33,6 +33,8 @@ tools: []
33
33
  # - type: backbone.todo
34
34
  # - type: backbone.skill
35
35
  # skills: [skill-name@latest]
36
+ # - type: backbone.bash
37
+ # approval: sandbox
36
38
  # - type: function
37
39
  # name: lookup_order
38
40
  # description: Look up an order by number
@@ -1,9 +1,8 @@
1
1
  import { Command } from "commander";
2
2
  import { readFileSync, writeFileSync } from "node:fs";
3
3
  import chalk from "chalk";
4
- import { getClient, resolveAuthHeader, runAction } from "../lib/client.js";
5
- import { resolveConfig, isJsonOutput } from "../lib/config.js";
6
- import { BackboneApiError } from "../lib/errors.js";
4
+ import { getClient, runAction } from "../lib/client.js";
5
+ import { isJsonOutput } from "../lib/config.js";
7
6
  import { formatDetail, withSpinner } from "../lib/output.js";
8
7
  import { fileToBlob, getMimeType } from "../lib/multipart.js";
9
8
  function mapFormat(format) {
@@ -136,34 +135,19 @@ function buildFormData(paths, opts) {
136
135
  formData.append("options", new Blob([JSON.stringify(pipelineOptions)], { type: "application/json" }));
137
136
  return formData;
138
137
  }
138
+ /**
139
+ * POST the multipart body through the typed client. The generated type describes `files` as string[],
140
+ * which FormData cannot match, so the placeholder body satisfies the type and bodySerializer sends the
141
+ * real parts; the client's auth and error middleware apply.
142
+ */
139
143
  async function multipartConvert(command, formData, endpoint, pipeline) {
140
- const config = resolveConfig(command);
141
- const authHeader = await resolveAuthHeader(config);
142
- const baseUrl = config.baseUrl.replace(/\/+$/, "");
143
- const url = pipeline
144
- ? `${baseUrl}${endpoint}?pipeline=${encodeURIComponent(pipeline)}`
145
- : `${baseUrl}${endpoint}`;
146
- const res = await fetch(url, {
147
- method: "POST",
148
- headers: { Authorization: authHeader },
149
- body: formData,
144
+ const client = getClient(command);
145
+ const { data } = await client.POST(endpoint, {
146
+ params: { query: { pipeline } },
147
+ body: { files: [] },
148
+ bodySerializer: () => formData,
150
149
  });
151
- if (!res.ok) {
152
- let body;
153
- try {
154
- body = await res.json();
155
- }
156
- catch {
157
- body = {
158
- error: res.statusText,
159
- message: `HTTP ${res.status}: ${res.statusText}`,
160
- status: res.status,
161
- timestamp: new Date().toISOString(),
162
- };
163
- }
164
- throw new BackboneApiError(body);
165
- }
166
- return (await res.json());
150
+ return data;
167
151
  }
168
152
  export function makeConvertCommand() {
169
153
  const cmd = new Command("convert").description("Convert documents");
@@ -16,8 +16,18 @@ function formatComparisonCell(result) {
16
16
  return chalk.red("ERROR");
17
17
  const scores = result.scores ?? [];
18
18
  if (scores.length > 0) {
19
- const avg = scores.reduce((sum, s) => sum + (s.score ?? 0), 0) / scores.length;
20
- return avg.toFixed(2);
19
+ // A SKIP carries a placeholder 0.0 that is not a rating, and a comment-only
20
+ // score has no number; neither takes part in the average (#693).
21
+ const rated = scores.filter((s) => s.label !== "SKIP" && s.score != null);
22
+ if (rated.length > 0) {
23
+ return (rated.reduce((sum, s) => sum + s.score, 0) / rated.length).toFixed(2);
24
+ }
25
+ // A label-only verdict (#1163) has no number; show its label, as the console does.
26
+ const verdict = scores.find((s) => s.label && s.label !== "SKIP");
27
+ if (verdict)
28
+ return String(verdict.label);
29
+ if (scores.every((s) => s.label === "SKIP"))
30
+ return chalk.dim("skipped");
21
31
  }
22
32
  return result.durationMs != null ? `${result.durationMs}ms` : chalk.dim("done");
23
33
  }
@@ -392,8 +402,14 @@ export function makeExperimentsCommand() {
392
402
  console.log(chalk.dim(`Page ${(page.number ?? 0) + 1} of ${page.totalPages ?? 0} (${page.totalElements ?? 0} total)`));
393
403
  }
394
404
  if (aggregates.length > 0) {
405
+ const skippedOf = (agg) => Object.entries((agg.skippedEvaluatorCounts ?? {}))
406
+ .filter(([, n]) => n > 0)
407
+ .map(([evaluatorId, n]) => `${evaluatorId}: ${n}`)
408
+ .join(", ");
409
+ // Only when something was skipped, so a clean run keeps its narrow table (#693).
410
+ const showSkipped = aggregates.some((agg) => skippedOf(agg) !== "");
395
411
  const aggTable = new Table({
396
- head: ["Variant", "Items", "Avg Duration", "Avg Tokens", "Total Cost"].map((h) => chalk.cyan(h)),
412
+ head: ["Variant", "Items", "Avg Duration", "Avg Tokens", "Total Cost", ...(showSkipped ? ["Skipped"] : [])].map((h) => chalk.cyan(h)),
397
413
  wordWrap: true,
398
414
  });
399
415
  for (const agg of aggregates) {
@@ -403,6 +419,7 @@ export function makeExperimentsCommand() {
403
419
  agg.avgDurationMs != null ? `${Math.round(agg.avgDurationMs)}ms` : "—",
404
420
  agg.avgTokens != null ? Math.round(agg.avgTokens) : "—",
405
421
  agg.totalCost != null ? `$${agg.totalCost}` : "—",
422
+ ...(showSkipped ? [skippedOf(agg) || "—"] : []),
406
423
  ]);
407
424
  }
408
425
  console.log(chalk.bold("\nAggregates:"));
@@ -14,7 +14,7 @@ export function makeProvidersCommand() {
14
14
  const { data } = await client.GET("/v1/providers", {
15
15
  params: { query: { search: opts.search, ...paginationParams(opts) } },
16
16
  });
17
- formatPage(data, command, ["id", "name", "type", "enabled", "createdAt"]);
17
+ formatPage(data, command, ["id", "name", "provider", "createdAt"]);
18
18
  });
19
19
  });
20
20
  cmd.addCommand(list);
@@ -36,7 +36,7 @@ export function makeProvidersCommand() {
36
36
  .description("Create a new provider")
37
37
  .requiredOption("-n, --name <name>", "Provider name")
38
38
  .requiredOption("--type <type>", "Provider type (openai, azure_openai, anthropic, xai, mistral, ollama)")
39
- .requiredOption("--provider-api-key <key>", "API key for the AI provider")
39
+ .option("--provider-api-key <key>", "API key for the AI provider (not needed for ollama)")
40
40
  .option("-c, --config <json>", "Provider config as JSON")
41
41
  .action(async (opts, command) => {
42
42
  await runAction(command, async () => {
@@ -54,7 +54,6 @@ export function makeProvidersCommand() {
54
54
  .argument("<id>", "Provider ID")
55
55
  .option("-n, --name <name>", "New name")
56
56
  .option("--type <type>", "New type")
57
- .option("--enabled <bool>", "Enable/disable")
58
57
  .option("-c, --config <json>", "New config as JSON")
59
58
  .action(async (id, opts, command) => {
60
59
  await runAction(command, async () => {
@@ -64,8 +63,6 @@ export function makeProvidersCommand() {
64
63
  body.name = opts.name;
65
64
  if (opts.type)
66
65
  body.provider = opts.type.toUpperCase();
67
- if (opts.enabled !== undefined)
68
- body.enabled = opts.enabled === "true";
69
66
  if (opts.config)
70
67
  body.config = JSON.parse(opts.config);
71
68
  const { data } = await client.PATCH("/v1/providers/{id}", {
@@ -95,13 +92,7 @@ export function makeProvidersCommand() {
95
92
  await runAction(command, async () => {
96
93
  const client = getClient(command);
97
94
  const { data } = await client.GET("/v1/providers/all");
98
- formatList(data, command, [
99
- "id",
100
- "name",
101
- "provider",
102
- "enabled",
103
- "createdAt",
104
- ]);
95
+ formatList(data, command, ["id", "name", "provider", "createdAt"]);
105
96
  });
106
97
  });
107
98
  cmd
@@ -149,18 +149,16 @@ export function makeSkillsCommand() {
149
149
  }
150
150
  ({ blob, filename } = fileToBlob(path));
151
151
  }
152
- const config = resolveConfig(command);
153
- const authHeader = await resolveAuthHeader(config);
154
152
  const formData = new FormData();
155
153
  formData.append("file", blob, filename);
154
+ const client = getClient(command);
156
155
  const data = await withSpinner("Importing skill...", async () => {
157
- const res = await fetch(`${config.baseUrl.replace(/\/+$/, "")}/v1/skills/import`, {
158
- method: "POST",
159
- headers: { Authorization: authHeader },
160
- body: formData,
156
+ // Placeholder body for the generated type; bodySerializer sends the real part.
157
+ const result = await client.POST("/v1/skills/import", {
158
+ body: { file: "" },
159
+ bodySerializer: () => formData,
161
160
  });
162
- await checkResponse(res);
163
- return res.json();
161
+ return result.data;
164
162
  });
165
163
  formatDetail(data, command);
166
164
  });
@@ -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
  }
@@ -116,6 +116,9 @@ export declare function parseAgentModel(model?: string): {
116
116
  * clipboard. Newlines and tabs stay. JSON output is never passed through this.
117
117
  */
118
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;
119
122
  export declare function shellQuote(value: string): string;
120
123
  /** "Connect a (h), allow the agent to use b (h) and reconnect c (h)". */
121
124
  export declare function connectionsSentence(connections: PendingConnection[]): string;
@@ -2,6 +2,7 @@ import chalk from "chalk";
2
2
  import { DEFAULT_CHAT_URL } from "./config.js";
3
3
  import { connectCallIds, connectionsPhrase, pendingConnectionsOf } from "./connect-pause.js";
4
4
  import { CliUsageError } from "./errors.js";
5
+ import { previewIsIncomplete, previewLines } from "./skills-apply-preview.js";
5
6
  export const CONVERSATION_MODES = ["plan", "ask", "auto"];
6
7
  /** `--mode` help text, in the words of the embedded chat's mode chip (#655). */
7
8
  export const MODE_OPTION_HELP = "Conversation mode: plan (read-only), ask (no automatic approver; calls that need approval wait for you), " +
@@ -88,6 +89,39 @@ const CONTROL_CHARS = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
88
89
  export function stripControl(s) {
89
90
  return s.replace(CONTROL_CHARS, "");
90
91
  }
92
+ /**
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`.
97
+ */
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;
122
+ }
123
+ return out;
124
+ }
91
125
  export function shellQuote(value) {
92
126
  if (/^[A-Za-z0-9._@/:-]+$/.test(value))
93
127
  return value;
@@ -119,9 +153,26 @@ function relayDecideCommand(ref, responseId, calls, withApprovals) {
119
153
  return (`2kw agents decide ${shellQuote(ref)} --response ${shellQuote(responseId)}${outputs.join("")}` +
120
154
  (withApprovals ? " --approve-all" : ""));
121
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
+ }
122
173
  /**
123
174
  * Output item types the envelope decodes, or that never hold a run: text, reasoning and finished
124
- * 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.
125
176
  * `backbone:tool_image` is an image a finished `ViewImage` call attached (#1244): it never holds a run.
126
177
  */
127
178
  const DECODED_ITEM_TYPES = new Set([
@@ -132,6 +183,7 @@ const DECODED_ITEM_TYPES = new Set([
132
183
  "backbone:approval_request",
133
184
  "backbone:connector_auth_request",
134
185
  "backbone:tool_image",
186
+ "mcp_approval_request",
135
187
  "mcp_call",
136
188
  "mcp_list_tools",
137
189
  ]);
@@ -155,18 +207,21 @@ export function buildRunEnvelope(result, agentRef, chatUrl = DEFAULT_CHAT_URL) {
155
207
  String(i.call_id),
156
208
  i.status === "incomplete" ? "incomplete" : "completed",
157
209
  ]));
158
- const pendingApprovals = output
159
- // Only an open request is pending; judge- or grant-decided requests (#634, #629) carry another status.
160
- .filter((i) => i.type === "backbone:approval_request" && (i.status === undefined || i.status === "in_progress"))
161
- .map((i) => ({
162
- approvalId: String(i.id),
163
- callId: String(i.call_id),
164
- tool: String(i.tool),
165
- arguments: parseArguments(i.arguments),
166
- policyClass: String(i.policy_class),
167
- reason: typeof i.reason === "string" && i.reason ? i.reason : null,
168
- preview: i.preview && typeof i.preview === "object" && !Array.isArray(i.preview) ? i.preview : null,
169
- }));
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
+ ];
170
225
  // The one decoding rule (#1086): only an open request with its id, call id, label and host.
171
226
  const connectRequests = pendingConnectionsOf(output);
172
227
  const pendingConnections = connectRequests.map(({ serverLabel, host, reason, destinations }) => ({
@@ -223,6 +278,13 @@ export function buildRunEnvelope(result, agentRef, chatUrl = DEFAULT_CHAT_URL) {
223
278
  : decide;
224
279
  }
225
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
+ }
226
288
  else if (status === "requires_tool_output" && pendingConnections.length > 0 && responseId) {
227
289
  // `agents run --continue` needs no input: the continuation re-checks access itself.
228
290
  const resume = ref
@@ -271,8 +333,15 @@ export function printRunText(env) {
271
333
  console.log(chalk.dim(` tools: ${env.toolCalls.map((c) => `${s(c.tool)} ${c.status === "incomplete" ? "✗" : "✓"}`).join(" ")}`));
272
334
  const printApprovals = () => env.pendingApprovals.forEach((a, i) => {
273
335
  console.log(` ${i + 1}. ${s(a.tool)} [${s(a.policyClass)}] ${s(a.approvalId)}`);
274
- // JSON.stringify escapes C0 controls but leaves DEL and C1 raw.
275
- console.log(chalk.dim(` ${s(JSON.stringify(a.arguments))}`));
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)) {
342
+ // JSON.stringify escapes C0 controls but leaves DEL and C1 raw.
343
+ console.log(chalk.dim(` ${s(JSON.stringify(a.arguments))}`));
344
+ }
276
345
  if (a.reason)
277
346
  console.log(chalk.dim(` reason: ${s(a.reason)}`));
278
347
  });
@@ -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):"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2kw/ai",
3
- "version": "6.4.0-dev.7",
3
+ "version": "7.0.0-dev.2",
4
4
  "description": "CLI for 2kw.ai — schema-driven document extraction, an OpenAI-compatible EU LLM gateway, transcription, prompts, datasets, and experiments from your terminal or agentic workflows. Ships as 2kw, backbone, and bb.",
5
5
  "keywords": [
6
6
  "cli",