@2kw/ai 6.3.0-dev.75 → 6.3.0-dev.84

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,5 +1,10 @@
1
1
  import { Command } from "commander";
2
2
  export declare function resolveRunInput(arg: string | undefined, inputFile: string | undefined, readStdin?: () => string | undefined): string;
3
+ /**
4
+ * The task text from the argument, then the file, then stdin; `undefined` when none is given. A
5
+ * `--continue` run may have none: a connect pause is continued with an empty input (#807).
6
+ */
7
+ export declare function readRunInput(arg: string | undefined, inputFile: string | undefined, readStdin?: () => string | undefined): string | undefined;
3
8
  export declare function makeAgentRunCommand(): Command;
4
9
  export declare function makeAgentDecideCommand(): Command;
5
10
  //# sourceMappingURL=agent-run.d.ts.map
@@ -1,7 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { getClient, runAction } from "../lib/client.js";
4
- import { isJsonOutput } from "../lib/config.js";
4
+ import { chatUrlFor, isJsonOutput, resolveConfig } from "../lib/config.js";
5
5
  import { BackboneApiError, CliUsageError } from "../lib/errors.js";
6
6
  import { withSpinner } from "../lib/output.js";
7
7
  import { resolveAgent, splitAgentRef, suggestAgents } from "../lib/agent-lookup.js";
@@ -11,6 +11,17 @@ import { PromptAbortedError, promptApprovals, readlineAsk } from "../lib/approva
11
11
  import { withModelOverride } from "../lib/agent-models.js";
12
12
  import { readStdinInput } from "./ai.js";
13
13
  export function resolveRunInput(arg, inputFile, readStdin = readStdinInput) {
14
+ const text = readRunInput(arg, inputFile, readStdin);
15
+ if (text === undefined) {
16
+ throw new CliUsageError("Input is required: pass it as an argument, with --input-file, or via stdin.");
17
+ }
18
+ return text;
19
+ }
20
+ /**
21
+ * The task text from the argument, then the file, then stdin; `undefined` when none is given. A
22
+ * `--continue` run may have none: a connect pause is continued with an empty input (#807).
23
+ */
24
+ export function readRunInput(arg, inputFile, readStdin = readStdinInput) {
14
25
  if (arg)
15
26
  return arg;
16
27
  if (inputFile) {
@@ -29,10 +40,7 @@ export function resolveRunInput(arg, inputFile, readStdin = readStdinInput) {
29
40
  throw new CliUsageError(`Input file ${inputFile} is empty.`);
30
41
  return text;
31
42
  }
32
- const piped = readStdin();
33
- if (piped)
34
- return piped;
35
- throw new CliUsageError("Input is required: pass it as an argument, with --input-file, or via stdin.");
43
+ return readStdin() || undefined;
36
44
  }
37
45
  async function withAgentSuggestions(client, agentRef, err) {
38
46
  if (!(err instanceof BackboneApiError) || err.status !== 404 || !err.message.startsWith("Agent not found")) {
@@ -56,8 +64,9 @@ async function withAgentSuggestions(client, agentRef, err) {
56
64
  */
57
65
  async function finishRun(command, client, target, first, opts) {
58
66
  const agentRef = `${target.name}${target.label ? `@${target.label}` : ""}${target.model ? `#${target.model}` : ""}`;
67
+ const chatUrl = chatUrlFor(resolveConfig(command).baseUrl);
59
68
  let result = first;
60
- let env = buildRunEnvelope(result, agentRef);
69
+ let env = buildRunEnvelope(result, agentRef, chatUrl);
61
70
  if (opts.interactive && env.status === "requires_approval") {
62
71
  try {
63
72
  const agent = await resolveAgent(client, target.name);
@@ -91,7 +100,7 @@ async function finishRun(command, client, target, first, opts) {
91
100
  }
92
101
  const decisions = decisionsFromAnswers(rows, answers);
93
102
  result = await withSpinner("Continuing...", () => continueWithDecisions(client, String(agent.id), responseId, decisions, target.label, target.model, target.mode));
94
- env = buildRunEnvelope(result, agentRef);
103
+ env = buildRunEnvelope(result, agentRef, chatUrl);
95
104
  }
96
105
  }
97
106
  catch (err) {
@@ -115,12 +124,12 @@ async function finishRun(command, client, target, first, opts) {
115
124
  }
116
125
  export function makeAgentRunCommand() {
117
126
  return new Command("run")
118
- .description("Run an agent on a task (exit 3 = paused for approval, 4 = needs client tool output, 5 = incomplete)")
127
+ .description("Run an agent on a task (exit 3 = paused for approval, 4 = needs client tool output or a connector connected in chat, 5 = incomplete)")
119
128
  .argument("<agent>", "Agent name or id, optionally with @label and #model")
120
- .argument("[input]", "Task text (or use --input-file, or pipe it on stdin)")
129
+ .argument("[input]", "Task text (or use --input-file, or pipe it on stdin); optional with --continue")
121
130
  .option("--input-file <path>", "Read the task text from a file")
122
131
  .option("--conversation <id>", "Continue a conversation")
123
- .option("--continue <responseId>", "Continue from a previous response")
132
+ .option("--continue <responseId>", "Continue from a previous response, with or without new input")
124
133
  .option("--model <model>", "Run this request on another model from the agent's list (sends <agent>#<model>)")
125
134
  .option("--mode <mode>", MODE_OPTION_HELP)
126
135
  .option("--no-input", "Never prompt for approvals, even on a terminal")
@@ -129,12 +138,14 @@ export function makeAgentRunCommand() {
129
138
  await runAction(command, async () => {
130
139
  const agentRef = withModelOverride(agentArg, opts.model);
131
140
  const mode = parseModeOption(opts.mode);
132
- const text = resolveRunInput(input, opts.inputFile);
141
+ // A continuation may send no new turn (#807): the connect pause's `next` is exactly that command.
142
+ const text = opts.continue ? readRunInput(input, opts.inputFile) : resolveRunInput(input, opts.inputFile);
133
143
  const client = getClient(command);
134
- // Without --mode the body stays exactly as before #656 (D2): input is the bare string.
144
+ const turn = text === undefined ? [] : [{ type: "message", role: "user", content: text }];
145
+ // Without --mode the body stays exactly as before #656 (D2): input is the bare string, or [] without one.
135
146
  const body = {
136
147
  model: `agent/${agentRef}`,
137
- input: mode ? [{ type: "message", role: "user", content: text }, modeItem(mode)] : text,
148
+ input: mode ? [...turn, modeItem(mode)] : text ?? [],
138
149
  stream: false,
139
150
  };
140
151
  if (opts.conversation)
@@ -1,7 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { getClient, runAction } from "../lib/client.js";
4
- import { isJsonOutput } from "../lib/config.js";
4
+ import { chatUrlFor, isJsonOutput, resolveConfig } from "../lib/config.js";
5
5
  import { CliUsageError } from "../lib/errors.js";
6
6
  import { withModelOverride } from "../lib/agent-models.js";
7
7
  import { formatList, withSpinner } from "../lib/output.js";
@@ -46,7 +46,7 @@ function printResponseResult(result, command, agentRef) {
46
46
  console.log(JSON.stringify(result, null, 2));
47
47
  return;
48
48
  }
49
- printRunText(buildRunEnvelope(result, agentRef));
49
+ printRunText(buildRunEnvelope(result, agentRef, chatUrlFor(resolveConfig(command).baseUrl)));
50
50
  if (result?.usage) {
51
51
  const usage = result.usage;
52
52
  console.log(chalk.dim(`[${result.model} | ${usage.input_tokens} in / ${usage.output_tokens} out / ${usage.total_tokens} total tokens]`));
@@ -25,6 +25,19 @@ export interface PendingToolCall {
25
25
  callId: string;
26
26
  arguments: unknown;
27
27
  }
28
+ /**
29
+ * A connector the run waits for the user to connect, allow or reconnect in chat.2kw.ai: an open
30
+ * `backbone:connector_auth_request` (#807 R12). The caller never answers the connect call; the
31
+ * continuation re-checks access itself.
32
+ */
33
+ export interface PendingConnection {
34
+ serverLabel: string;
35
+ host: string;
36
+ /** `connect`, `allow` or `reconnect`. */
37
+ reason: string;
38
+ /** The egress difference an allow is re-asked for; absent otherwise. */
39
+ destinations?: string[];
40
+ }
28
41
  /** The end user's conversation mode (epic &59); the server matches these three values exactly. */
29
42
  export type ConversationMode = "plan" | "ask" | "auto";
30
43
  export declare const CONVERSATION_MODES: readonly ConversationMode[];
@@ -57,6 +70,7 @@ export interface RunEnvelope {
57
70
  toolCalls: ToolCallSummary[];
58
71
  pendingApprovals: PendingApproval[];
59
72
  pendingToolCalls: PendingToolCall[];
73
+ pendingConnections: PendingConnection[];
60
74
  incompleteReason: string | null;
61
75
  usage: {
62
76
  inputTokens: number;
@@ -102,7 +116,13 @@ export declare function parseAgentModel(model?: string): {
102
116
  */
103
117
  export declare function stripControl(s: string): string;
104
118
  export declare function shellQuote(value: string): string;
105
- export declare function buildRunEnvelope(result: ResponsesResult | undefined, agentRef?: string): RunEnvelope;
119
+ /** "Connect a (h), allow the agent to use b (h) and reconnect c (h)". */
120
+ export declare function connectionsSentence(connections: PendingConnection[]): string;
121
+ /**
122
+ * @param chatUrl the chat web host of the API the run went to ({@link chatUrlFor}); a connect pause's
123
+ * `next` sends the user to its Connectors page.
124
+ */
125
+ export declare function buildRunEnvelope(result: ResponsesResult | undefined, agentRef?: string, chatUrl?: string): RunEnvelope;
106
126
  /** Human-readable rendering of an envelope on stdout. Every value printed is passed through {@link stripControl}. */
107
127
  export declare function printRunText(env: RunEnvelope): void;
108
128
  export {};
@@ -1,4 +1,5 @@
1
1
  import chalk from "chalk";
2
+ import { DEFAULT_CHAT_URL } from "./config.js";
2
3
  import { CliUsageError } from "./errors.js";
3
4
  export const CONVERSATION_MODES = ["plan", "ask", "auto"];
4
5
  /** `--mode` help text, in the words of the embedded chat's mode chip (#655). */
@@ -91,7 +92,78 @@ export function shellQuote(value) {
91
92
  return value;
92
93
  return `'${value.replace(/'/g, `'\\''`)}'`;
93
94
  }
94
- export function buildRunEnvelope(result, agentRef) {
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
+ /**
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).
112
+ */
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
+ }
123
+ }
124
+ return ids;
125
+ }
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}`;
133
+ }
134
+ /** "Connect a (h), allow the agent to use b (h) and reconnect c (h)". */
135
+ 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] ?? "";
138
+ return joined.charAt(0).toUpperCase() + joined.slice(1);
139
+ }
140
+ /** Joins a connect pause's instruction to the command that continues it; {@link printRunText} breaks the line there. */
141
+ const THEN_RUN = ", then run: ";
142
+ /**
143
+ * 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.
145
+ */
146
+ const DECODED_ITEM_TYPES = new Set([
147
+ "message",
148
+ "reasoning",
149
+ "function_call",
150
+ "function_call_output",
151
+ "backbone:approval_request",
152
+ "backbone:connector_auth_request",
153
+ "mcp_call",
154
+ "mcp_list_tools",
155
+ ]);
156
+ function undecodedPause(output) {
157
+ const types = [...new Set(output.map((i) => String(i.type)).filter((t) => !DECODED_ITEM_TYPES.has(t)))];
158
+ return types.length > 0
159
+ ? `This CLI cannot show or answer ${types.join(", ")}.`
160
+ : "This CLI cannot tell what the run waits for.";
161
+ }
162
+ /**
163
+ * @param chatUrl the chat web host of the API the run went to ({@link chatUrlFor}); a connect pause's
164
+ * `next` sends the user to its Connectors page.
165
+ */
166
+ export function buildRunEnvelope(result, agentRef, chatUrl = DEFAULT_CHAT_URL) {
95
167
  const output = result?.output ?? [];
96
168
  const { agent, version } = parseAgentModel(result?.model);
97
169
  // call_id → status of its tool output; a failed server-side tool run is marked `incomplete`.
@@ -113,7 +185,8 @@ export function buildRunEnvelope(result, agentRef) {
113
185
  reason: typeof i.reason === "string" && i.reason ? i.reason : null,
114
186
  preview: i.preview && typeof i.preview === "object" && !Array.isArray(i.preview) ? i.preview : null,
115
187
  }));
116
- const withheldCallIds = new Set(pendingApprovals.map((a) => a.callId));
188
+ const pendingConnections = pendingConnectionsOf(output);
189
+ const withheldCallIds = new Set([...pendingApprovals.map((a) => a.callId), ...connectCallIds(output, pendingConnections)]);
117
190
  const toolCalls = [];
118
191
  const pendingToolCalls = [];
119
192
  for (const item of output) {
@@ -137,16 +210,35 @@ export function buildRunEnvelope(result, agentRef) {
137
210
  status = "incomplete";
138
211
  break;
139
212
  case "requires_action":
140
- status = pendingToolCalls.length > 0 || pendingApprovals.length === 0 ? "requires_tool_output" : "requires_approval";
213
+ // A connect pause keeps exit 4 (§8, CLI agents D14): the CLI cannot answer it either.
214
+ status =
215
+ pendingToolCalls.length > 0 || pendingConnections.length > 0 || pendingApprovals.length === 0
216
+ ? "requires_tool_output"
217
+ : "requires_approval";
141
218
  break;
142
219
  default:
143
220
  throw new Error(`Unexpected response status: ${result?.status}`);
144
221
  }
145
222
  const responseId = result?.id ?? null;
146
223
  const ref = agentRef ?? agent;
147
- const next = status === "requires_approval" && ref && responseId
148
- ? `2kw agents decide ${shellQuote(ref)} --response ${responseId} --approve-all`
149
- : null;
224
+ let next = null;
225
+ if (status === "requires_approval" && ref && responseId) {
226
+ next = `2kw agents decide ${shellQuote(ref)} --response ${responseId} --approve-all`;
227
+ }
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.
230
+ // `agents run --continue` needs no input: the continuation re-checks access itself.
231
+ const resume = ref
232
+ ? `${THEN_RUN}2kw agents run ${shellQuote(ref)} --continue ${shellQuote(responseId)}`
233
+ : `, then run the agent again with --continue ${shellQuote(responseId)}`;
234
+ next = `${connectionsSentence(pendingConnections)} in ${chatUrl}/connectors${resume}`;
235
+ }
236
+ else if (status === "requires_tool_output" && pendingConnections.length === 0 && pendingToolCalls.length === 0 && responseId) {
237
+ // Paused on nothing the envelope decodes: say so, rather than exit 4 without a word.
238
+ next =
239
+ `${undecodedPause(output)} Response ${responseId} stays paused: answer it in ${chatUrl}/ ` +
240
+ "or from a client that continues it by previous_response_id.";
241
+ }
150
242
  return {
151
243
  status,
152
244
  mode: responseMode(result),
@@ -158,6 +250,7 @@ export function buildRunEnvelope(result, agentRef) {
158
250
  toolCalls,
159
251
  pendingApprovals,
160
252
  pendingToolCalls,
253
+ pendingConnections,
161
254
  incompleteReason: result?.incomplete_details?.reason ?? null,
162
255
  usage: result?.usage
163
256
  ? { inputTokens: result.usage.input_tokens ?? 0, outputTokens: result.usage.output_tokens ?? 0 }
@@ -192,8 +285,26 @@ export function printRunText(env) {
192
285
  console.log(`\nDecide with:\n ${s(env.next)}`);
193
286
  }
194
287
  else if (env.status === "requires_tool_output") {
195
- console.log(chalk.yellow("\nPaused: the agent needs client-side tool output this CLI cannot provide:"));
196
- env.pendingToolCalls.forEach((c) => console.log(` - ${s(c.tool)} (${s(c.callId)})`));
288
+ if (env.pendingConnections.length) {
289
+ console.log(chalk.yellow("\nPaused: the agent needs you to connect in chat:"));
290
+ env.pendingConnections.forEach((c) => {
291
+ console.log(` - ${s(connectionsSentence([c]))}`);
292
+ if (c.destinations?.length)
293
+ console.log(chalk.dim(` new destinations: ${s(c.destinations.join(", "))}`));
294
+ });
295
+ // The continue command on its own line, ready to copy.
296
+ if (env.next)
297
+ console.log(`\n${s(env.next.replace(THEN_RUN, ", then run:\n "))}`);
298
+ }
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)})`));
302
+ }
303
+ if (!env.pendingConnections.length && !env.pendingToolCalls.length) {
304
+ console.log(chalk.yellow("\nPaused on something this CLI cannot show:"));
305
+ if (env.next)
306
+ console.log(` ${s(env.next)}`);
307
+ }
197
308
  }
198
309
  else if (env.status === "incomplete") {
199
310
  console.log(chalk.yellow(`\nIncomplete: ${s(env.incompleteReason ?? "unknown reason")}`));
@@ -52,6 +52,14 @@ 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";
57
+ /**
58
+ * The chat web host that belongs to an API base URL, where a member connects a connector
59
+ * (`<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.
61
+ */
62
+ export declare function chatUrlFor(baseUrl: string, env?: NodeJS.ProcessEnv): string;
55
63
  declare const store: Conf<BackboneConfigStore>;
56
64
  export { store };
57
65
  export declare function validateContextName(name: string): void;
@@ -36,6 +36,34 @@ export function defaultAuthUrlFor(baseUrl) {
36
36
  return DEFAULT_AUTH_URL;
37
37
  }
38
38
  }
39
+ /** Fallback chat.2kw.ai origin when the API host is not one we recognise. */
40
+ export const DEFAULT_CHAT_URL = "https://chat.2kw.ai";
41
+ /** Known API-host → chat web host mappings; a connect pause points the user there (#807 R11). */
42
+ const CHAT_URL_BY_API_HOST = {
43
+ "api.2kw.ai": "https://chat.2kw.ai",
44
+ "api-dev.2kw.ai": "https://chat-dev.2kw.ai",
45
+ "backbone.manfred-kunze.dev": "https://chat.2kw.ai",
46
+ "localhost:8080": "http://localhost:3000",
47
+ "127.0.0.1:8080": "http://localhost:3000",
48
+ };
49
+ /**
50
+ * The chat web host that belongs to an API base URL, where a member connects a connector
51
+ * (`<chat>/connectors`). `AI_2KW_CHAT_URL` overrides the mapping, for deployments the map
52
+ * does not know; a trailing slash is dropped so callers can append a path.
53
+ */
54
+ export function chatUrlFor(baseUrl, env = process.env) {
55
+ const override = env.AI_2KW_CHAT_URL?.trim();
56
+ if (override)
57
+ return override.replace(/\/+$/, "");
58
+ try {
59
+ const host = new URL(baseUrl).host;
60
+ // hasOwn, as in defaultAuthUrlFor: a host named after an Object.prototype key must not resolve.
61
+ return Object.hasOwn(CHAT_URL_BY_API_HOST, host) ? CHAT_URL_BY_API_HOST[host] : DEFAULT_CHAT_URL;
62
+ }
63
+ catch {
64
+ return DEFAULT_CHAT_URL;
65
+ }
66
+ }
39
67
  // One-shot flag so we only print the deprecation warning once per process,
40
68
  // even if resolveConfig is called multiple times across commands.
41
69
  let legacyEnvWarned = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2kw/ai",
3
- "version": "6.3.0-dev.75",
3
+ "version": "6.3.0-dev.84",
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",