@gajae-code/coding-agent 0.15.2 → 0.15.4
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/CHANGELOG.md +43 -1
- package/dist/types/config/autorouting-tier-map.d.ts +0 -9
- package/dist/types/config/file-lock.d.ts +7 -1
- package/dist/types/config/keybindings.d.ts +13 -3
- package/dist/types/config/model-registry.d.ts +5 -0
- package/dist/types/config/model-resolver.d.ts +1 -0
- package/dist/types/config/settings-schema.d.ts +65 -1
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-state.d.ts +42 -2
- package/dist/types/extensibility/extensions/types.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +12 -0
- package/dist/types/gjc-runtime/workflow-placeholder.d.ts +6 -0
- package/dist/types/main.d.ts +1 -1
- package/dist/types/modes/action-registry.d.ts +1 -0
- package/dist/types/modes/components/hook-selector.d.ts +1 -0
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -1
- package/dist/types/modes/components/welcome.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +16 -1
- package/dist/types/modes/controllers/runtime-mcp-command-controller.d.ts +42 -0
- package/dist/types/modes/interactive-mode.d.ts +7 -0
- package/dist/types/modes/shared/oauth-url-copy.d.ts +9 -0
- package/dist/types/modes/types.d.ts +3 -0
- package/dist/types/modes/ui-language.d.ts +2 -1
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
- package/dist/types/sdk/broker/session-index.d.ts +18 -0
- package/dist/types/sdk/bus/index.d.ts +4 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
- package/dist/types/sdk/cli/session-cli.d.ts +1 -1
- package/dist/types/sdk/lifecycle/service.d.ts +32 -5
- package/dist/types/sdk/session.d.ts +5 -0
- package/dist/types/session/agent-session.d.ts +4 -0
- package/dist/types/session/fallback-chain-controller.d.ts +8 -0
- package/dist/types/slash-commands/helpers/aside.d.ts +54 -0
- package/dist/types/tools/ask.d.ts +9 -0
- package/dist/types/utils/clipboard.d.ts +9 -1
- package/package.json +7 -7
- package/scripts/generate-sdk-adapter-parity-manifest.ts +5 -2
- package/scripts/generate-sdk-operation-inventory.ts +3 -0
- package/src/commands/sdk.ts +2 -2
- package/src/config/autorouting-tier-map.ts +0 -9
- package/src/config/file-lock.ts +15 -1
- package/src/config/keybindings.ts +13 -3
- package/src/config/model-registry.ts +132 -21
- package/src/config/model-resolver.ts +2 -1
- package/src/config/models-config-schema.ts +2 -2
- package/src/config/settings-schema.ts +66 -1
- package/src/config/settings.ts +1 -1
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/question-state.ts +235 -4
- package/src/coordinator-mcp/server.ts +402 -2
- package/src/dap/client.ts +19 -8
- package/src/deep-interview/render-middleware.ts +6 -2
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/gjc-runtime/deep-interview-stage.ts +20 -1
- package/src/gjc-runtime/deep-interview-state.ts +3 -1
- package/src/gjc-runtime/ralplan-runtime.ts +6 -1
- package/src/gjc-runtime/session-state-lock.ts +11 -2
- package/src/gjc-runtime/ultragoal-runtime.ts +5 -0
- package/src/gjc-runtime/workflow-placeholder.ts +33 -0
- package/src/internal-urls/docs-index.generated.ts +11 -9
- package/src/lsp/client.ts +39 -4
- package/src/main.ts +42 -11
- package/src/modes/action-registry.ts +7 -0
- package/src/modes/components/hook-selector.ts +2 -1
- package/src/modes/components/iterm-pet-transport.ts +4 -2
- package/src/modes/components/runtime-mcp-add-wizard.ts +38 -1
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/tool-status-header.ts +149 -41
- package/src/modes/components/welcome.ts +8 -0
- package/src/modes/controllers/extension-ui-controller.ts +1 -0
- package/src/modes/controllers/goal-mode-controller.ts +7 -2
- package/src/modes/controllers/input-controller.ts +141 -7
- package/src/modes/controllers/plan-mode-controller.ts +12 -4
- package/src/modes/controllers/runtime-mcp-command-controller.ts +164 -20
- package/src/modes/controllers/selector-controller.ts +11 -2
- package/src/modes/interactive-mode.ts +88 -1
- package/src/modes/shared/oauth-url-copy.ts +33 -0
- package/src/modes/types.ts +3 -0
- package/src/modes/ui-language.ts +44 -3
- package/src/runtime-mcp/oauth-flow.ts +19 -8
- package/src/runtime-mcp/transports/stdio.ts +27 -13
- package/src/sdk/acp/adapter.ts +29 -2
- package/src/sdk/broker/broker.ts +26 -6
- package/src/sdk/broker/lifecycle-ledger.ts +28 -1
- package/src/sdk/broker/lifecycle.ts +664 -5
- package/src/sdk/broker/session-index.ts +246 -0
- package/src/sdk/broker/transport.ts +1 -0
- package/src/sdk/bus/index.ts +135 -38
- package/src/sdk/bus/natives-augment.d.ts +20 -1
- package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
- package/src/sdk/bus/telegram-daemon.ts +3 -0
- package/src/sdk/cli/session-cli.ts +34 -7
- package/src/sdk/lifecycle/service.ts +166 -11
- package/src/sdk/mcp/server.ts +2 -1
- package/src/sdk/protocol/operation-inventory.generated.json +40 -0
- package/src/sdk/protocol/operation-registry.ts +2 -1
- package/src/sdk/session.ts +91 -7
- package/src/session/agent-session.ts +473 -66
- package/src/session/fallback-chain-controller.ts +17 -0
- package/src/setup/frictionless-onboarding.ts +4 -4
- package/src/slash-commands/aside-command.test.ts +366 -0
- package/src/slash-commands/builtin-registry.ts +19 -0
- package/src/slash-commands/helpers/aside.ts +299 -0
- package/src/tools/ask-contract.ts +54 -3
- package/src/tools/ask.ts +54 -6
- package/src/utils/clipboard.ts +37 -28
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { sanitizeDisplayLine, sanitizeText } from "@gajae-code/utils";
|
|
5
|
+
import { type ExecOptions, type ExecResult, execCommand } from "../../exec/exec";
|
|
6
|
+
import type { ParsedSlashCommand, SlashCommandResult, SlashCommandRuntime } from "../types";
|
|
7
|
+
import { commandConsumed, errorMessage, parseSubcommand, usage } from "./parse";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Aside CLI discovery and `/aside` dispatch.
|
|
11
|
+
*
|
|
12
|
+
* Probe-only: never runs the installer. Missing-CLI handling is the caller's
|
|
13
|
+
* decision. This is an explicit composer frontend, not a GJC browser backend.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const ASIDE_INSTALL_URL = "https://releases.aside.com/install.sh";
|
|
17
|
+
export const ASIDE_INSTALL_COMMAND = "curl -fsSL https://releases.aside.com/install.sh | bash";
|
|
18
|
+
|
|
19
|
+
const ASIDE_HELP_VERBS = new Set(["help", "-h", "--help"]);
|
|
20
|
+
|
|
21
|
+
const ASIDE_STATUS_TIMEOUT_MS = 15_000;
|
|
22
|
+
const ASIDE_EXEC_TIMEOUT_MS = 10 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
export const ASIDE_USAGE = [
|
|
25
|
+
"Aside CLI (explicit opt-in; does not enable GJC browser-control by default)",
|
|
26
|
+
" /aside Show CLI path and usage",
|
|
27
|
+
" /aside <prompt> Run `aside exec <prompt>`",
|
|
28
|
+
" /aside exec [args] Pass argv through to `aside exec`",
|
|
29
|
+
" /aside repl Not available inside GJC; run `aside repl` in a terminal",
|
|
30
|
+
" /aside mcp Print MCP registration using the resolved CLI path",
|
|
31
|
+
" /aside account [args] Run `aside account` (list/status/use)",
|
|
32
|
+
" /aside help Show this help",
|
|
33
|
+
].join("\n");
|
|
34
|
+
|
|
35
|
+
export type AsideCliProbe =
|
|
36
|
+
| { ok: true; path: string }
|
|
37
|
+
| { ok: false; searched: string[]; manualInstallCommand: string; url: string };
|
|
38
|
+
|
|
39
|
+
export type AsideWhich = (command: string) => string | null | undefined;
|
|
40
|
+
export type AsideIsExecutable = (filePath: string) => boolean;
|
|
41
|
+
export type AsideExec = (command: string, args: string[], cwd: string, options?: ExecOptions) => Promise<ExecResult>;
|
|
42
|
+
|
|
43
|
+
export interface AsideHandlerOptions {
|
|
44
|
+
homedir?: () => string;
|
|
45
|
+
which?: AsideWhich;
|
|
46
|
+
isExecutable?: AsideIsExecutable;
|
|
47
|
+
exec?: AsideExec;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Candidate absolute paths for the `aside` CLI, in priority order. */
|
|
51
|
+
export function asideCliCandidates(home = os.homedir()): string[] {
|
|
52
|
+
return [
|
|
53
|
+
path.join(home, ".local", "bin", "aside"),
|
|
54
|
+
path.join(home, ".aside", "cli", "Aside CLI.app", "Contents", "MacOS", "aside"),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function defaultIsExecutable(filePath: string): boolean {
|
|
59
|
+
try {
|
|
60
|
+
const st = fs.statSync(filePath);
|
|
61
|
+
if (!st.isFile()) return false;
|
|
62
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function defaultWhich(command: string): string | null {
|
|
70
|
+
return Bun.which(command) ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isSafeAsideCliPath(value: string): boolean {
|
|
74
|
+
return sanitizeText(value) === value && !/[\r\n]/u.test(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Resolve the first executable Aside CLI path, or null when none is found. */
|
|
78
|
+
export function resolveAsideCliPath(
|
|
79
|
+
home = os.homedir(),
|
|
80
|
+
options: Pick<AsideHandlerOptions, "which" | "isExecutable"> = {},
|
|
81
|
+
): string | null {
|
|
82
|
+
const isExecutable = options.isExecutable ?? defaultIsExecutable;
|
|
83
|
+
const which = options.which ?? defaultWhich;
|
|
84
|
+
for (const candidate of asideCliCandidates(home)) {
|
|
85
|
+
if (isExecutable(candidate) && isSafeAsideCliPath(candidate)) return candidate;
|
|
86
|
+
}
|
|
87
|
+
const fromPath = which("aside");
|
|
88
|
+
if (fromPath && isExecutable(fromPath) && isSafeAsideCliPath(fromPath)) return fromPath;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Structured probe result for callers that need to guide installation. */
|
|
93
|
+
export function probeAsideCli(
|
|
94
|
+
home = os.homedir(),
|
|
95
|
+
options: Pick<AsideHandlerOptions, "which" | "isExecutable"> = {},
|
|
96
|
+
): AsideCliProbe {
|
|
97
|
+
const searched = [...asideCliCandidates(home), "PATH (aside)"];
|
|
98
|
+
const found = resolveAsideCliPath(home, options);
|
|
99
|
+
if (found) return { ok: true, path: found };
|
|
100
|
+
return { ok: false, searched, manualInstallCommand: ASIDE_INSTALL_COMMAND, url: ASIDE_INSTALL_URL };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function posixQuote(value: string): string {
|
|
104
|
+
if (value.length === 0) return "''";
|
|
105
|
+
if (/^[A-Za-z0-9_./:-]+$/.test(value)) return value;
|
|
106
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Quote a path for the PowerShell command shown on native Windows. */
|
|
110
|
+
export function windowsPowerShellQuote(value: string): string {
|
|
111
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function quoteAsidePath(value: string, platform = process.platform): string {
|
|
115
|
+
return platform === "win32" ? windowsPowerShellQuote(value) : posixQuote(value);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type AsideArgvResult = { ok: true; args: string[] } | { ok: false; error: string };
|
|
119
|
+
|
|
120
|
+
/** Parse user-provided Aside argv without silently accepting malformed quoting. */
|
|
121
|
+
export function parseAsideArgv(input: string): AsideArgvResult {
|
|
122
|
+
const args: string[] = [];
|
|
123
|
+
let current = "";
|
|
124
|
+
let tokenStarted = false;
|
|
125
|
+
let quote: "'" | '"' | null = null;
|
|
126
|
+
let escaped = false;
|
|
127
|
+
|
|
128
|
+
for (const char of input) {
|
|
129
|
+
if (escaped) {
|
|
130
|
+
current += char;
|
|
131
|
+
tokenStarted = true;
|
|
132
|
+
escaped = false;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (quote) {
|
|
136
|
+
if (char === quote) {
|
|
137
|
+
quote = null;
|
|
138
|
+
} else if (char === "\\" && quote === '"') {
|
|
139
|
+
escaped = true;
|
|
140
|
+
} else {
|
|
141
|
+
current += char;
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (char === "'" || char === '"') {
|
|
146
|
+
quote = char;
|
|
147
|
+
tokenStarted = true;
|
|
148
|
+
} else if (char === "\\") {
|
|
149
|
+
escaped = true;
|
|
150
|
+
tokenStarted = true;
|
|
151
|
+
} else if (char === " " || char === "\t") {
|
|
152
|
+
if (tokenStarted) {
|
|
153
|
+
args.push(current);
|
|
154
|
+
current = "";
|
|
155
|
+
tokenStarted = false;
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
current += char;
|
|
159
|
+
tokenStarted = true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (escaped) return { ok: false, error: "unfinished escape" };
|
|
164
|
+
if (quote) return { ok: false, error: "unterminated quote" };
|
|
165
|
+
if (tokenStarted) args.push(current);
|
|
166
|
+
return { ok: true, args };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function formatAsideMissingCli(probe: Extract<AsideCliProbe, { ok: false }>): string {
|
|
170
|
+
const installLabel = process.platform === "win32" ? "Install with in WSL or Git Bash:" : "Install with:";
|
|
171
|
+
return [
|
|
172
|
+
"Aside CLI was not found.",
|
|
173
|
+
"Looked in:",
|
|
174
|
+
...probe.searched.map(entry => ` - ${entry}`),
|
|
175
|
+
installLabel,
|
|
176
|
+
` ${probe.manualInstallCommand}`,
|
|
177
|
+
`Docs: ${probe.url}`,
|
|
178
|
+
"Then retry /aside, or register MCP with the concrete binary path.",
|
|
179
|
+
].join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function formatAsideMcpRegistration(cliPath: string, platform = process.platform): string {
|
|
183
|
+
const safeCliPath = sanitizeText(cliPath).replace(/[\r\n]/gu, " ");
|
|
184
|
+
const quotedCliPath = quoteAsidePath(safeCliPath, platform);
|
|
185
|
+
return [
|
|
186
|
+
`Aside CLI: ${safeCliPath}`,
|
|
187
|
+
"`/aside mcp` does not start a stdio server inside GJC.",
|
|
188
|
+
"Register the user-owned MCP definition with the resolved binary:",
|
|
189
|
+
` gjc mcp add aside ${quotedCliPath} mcp --project`,
|
|
190
|
+
"Inspect the redacted record with `gjc mcp list --json`.",
|
|
191
|
+
"Do not paste cookies, screenshots, or private Aside profile paths into issues or PRs.",
|
|
192
|
+
].join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function formatExecOutput(result: ExecResult): string {
|
|
196
|
+
const body = [sanitizeText(result.stdout).trim(), sanitizeText(result.stderr).trim()].filter(Boolean).join("\n");
|
|
197
|
+
if (result.killed) {
|
|
198
|
+
return body ? `${body}\nAside CLI timed out or was cancelled.` : "Aside CLI timed out or was cancelled.";
|
|
199
|
+
}
|
|
200
|
+
if (!body) {
|
|
201
|
+
return result.code === 0
|
|
202
|
+
? "Aside CLI exited 0 with no output."
|
|
203
|
+
: `Aside CLI exited ${result.code} with no output.`;
|
|
204
|
+
}
|
|
205
|
+
if (result.code !== 0) return `${body}\n(exit ${result.code})`;
|
|
206
|
+
return body;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function createAsideHandler(options: AsideHandlerOptions = {}) {
|
|
210
|
+
const homedir = options.homedir ?? os.homedir;
|
|
211
|
+
const exec = options.exec ?? execCommand;
|
|
212
|
+
const probeOptions = { which: options.which, isExecutable: options.isExecutable };
|
|
213
|
+
|
|
214
|
+
async function runCli(
|
|
215
|
+
runtime: SlashCommandRuntime,
|
|
216
|
+
cliPath: string,
|
|
217
|
+
args: string[],
|
|
218
|
+
timeout: number,
|
|
219
|
+
): Promise<SlashCommandResult> {
|
|
220
|
+
try {
|
|
221
|
+
const result = await exec(cliPath, args, runtime.cwd, { timeout });
|
|
222
|
+
await runtime.output(formatExecOutput(result));
|
|
223
|
+
return result.code === 0 && !result.killed
|
|
224
|
+
? commandConsumed()
|
|
225
|
+
: { consumed: true, exitCode: result.code || 1 };
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return usage(`Aside CLI failed: ${errorMessage(error)}`, runtime);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return async function handleAsideAcp(
|
|
232
|
+
command: ParsedSlashCommand,
|
|
233
|
+
runtime: SlashCommandRuntime,
|
|
234
|
+
): Promise<SlashCommandResult> {
|
|
235
|
+
const { verb, rest } = parseSubcommand(command.args);
|
|
236
|
+
if (ASIDE_HELP_VERBS.has(verb)) return usage(ASIDE_USAGE, runtime);
|
|
237
|
+
|
|
238
|
+
const probe = probeAsideCli(homedir(), probeOptions);
|
|
239
|
+
|
|
240
|
+
if (verb === "mcp") {
|
|
241
|
+
if (rest) return usage("Usage: /aside mcp", runtime);
|
|
242
|
+
if (!probe.ok) return usage(formatAsideMissingCli(probe), runtime);
|
|
243
|
+
return usage(formatAsideMcpRegistration(probe.path), runtime);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (verb === "repl") {
|
|
247
|
+
if (rest) return usage("Usage: /aside repl", runtime);
|
|
248
|
+
if (!probe.ok) return usage(formatAsideMissingCli(probe), runtime);
|
|
249
|
+
return usage(
|
|
250
|
+
[
|
|
251
|
+
"`/aside repl` needs a real terminal TTY.",
|
|
252
|
+
`Run this outside GJC: ${quoteAsidePath(probe.path)} repl`,
|
|
253
|
+
].join("\n"),
|
|
254
|
+
runtime,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!verb) {
|
|
259
|
+
if (!probe.ok) return usage(`${formatAsideMissingCli(probe)}\n\n${ASIDE_USAGE}`, runtime);
|
|
260
|
+
const lines = [`Aside CLI: ${probe.path}`];
|
|
261
|
+
try {
|
|
262
|
+
const version = await exec(probe.path, ["--version"], runtime.cwd, { timeout: ASIDE_STATUS_TIMEOUT_MS });
|
|
263
|
+
const label = sanitizeDisplayLine(version.stdout).trim() || sanitizeDisplayLine(version.stderr).trim();
|
|
264
|
+
if (version.code === 0 && label) lines.push(`Version: ${label.split("\n")[0]}`);
|
|
265
|
+
} catch {
|
|
266
|
+
// Status should still print when `--version` is unavailable.
|
|
267
|
+
}
|
|
268
|
+
lines.push(ASIDE_USAGE);
|
|
269
|
+
await runtime.output(lines.join("\n"));
|
|
270
|
+
return commandConsumed();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!probe.ok) return usage(formatAsideMissingCli(probe), runtime);
|
|
274
|
+
|
|
275
|
+
if (verb === "account") {
|
|
276
|
+
const parsed = parseAsideArgv(rest);
|
|
277
|
+
if (!parsed.ok) return usage(`Invalid Aside arguments: ${parsed.error}.`, runtime);
|
|
278
|
+
return runCli(runtime, probe.path, ["account", ...parsed.args], ASIDE_STATUS_TIMEOUT_MS);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (verb === "exec") {
|
|
282
|
+
const parsed = parseAsideArgv(rest);
|
|
283
|
+
if (!parsed.ok) return usage(`Invalid Aside arguments: ${parsed.error}.`, runtime);
|
|
284
|
+
const argv = parsed.args;
|
|
285
|
+
if (argv.length === 0) return usage("Usage: /aside exec [args] <prompt>", runtime);
|
|
286
|
+
return runCli(runtime, probe.path, ["exec", ...argv], ASIDE_EXEC_TIMEOUT_MS);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (verb.startsWith("-")) {
|
|
290
|
+
const parsed = parseAsideArgv(command.args);
|
|
291
|
+
if (!parsed.ok) return usage(`Invalid Aside arguments: ${parsed.error}.`, runtime);
|
|
292
|
+
return runCli(runtime, probe.path, ["exec", ...parsed.args], ASIDE_EXEC_TIMEOUT_MS);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return runCli(runtime, probe.path, ["exec", command.args.trim()], ASIDE_EXEC_TIMEOUT_MS);
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export const handleAsideAcp = createAsideHandler();
|
|
@@ -7,6 +7,11 @@ import { INTENT_FIELD } from "@gajae-code/agent-core";
|
|
|
7
7
|
import type { RawArgumentValidationResult } from "@gajae-code/ai/types";
|
|
8
8
|
import * as z from "zod/v4";
|
|
9
9
|
import { deepInterviewCharacterCount } from "../gjc-runtime/deep-interview-state";
|
|
10
|
+
import {
|
|
11
|
+
isLegacyDeepInterviewPlaceholder,
|
|
12
|
+
isWorkflowPlaceholderText,
|
|
13
|
+
WORKFLOW_PLACEHOLDER_CORRECTION,
|
|
14
|
+
} from "../gjc-runtime/workflow-placeholder";
|
|
10
15
|
|
|
11
16
|
function deepInterviewBoundedString(maximum: number) {
|
|
12
17
|
return z.string().superRefine((value, context) => {
|
|
@@ -146,6 +151,13 @@ function createQuestionItemSchema(deepInterviewSchema: z.ZodType<DeepInterviewMe
|
|
|
146
151
|
workflowGate: WorkflowGateMeta.describe("optional workflow gate stage/kind override").optional(),
|
|
147
152
|
})
|
|
148
153
|
.superRefine((value, context) => {
|
|
154
|
+
if (value.deepInterview !== undefined && isWorkflowPlaceholderText(value.question)) {
|
|
155
|
+
context.addIssue({
|
|
156
|
+
code: "custom",
|
|
157
|
+
message: `deep-interview question body must ${WORKFLOW_PLACEHOLDER_CORRECTION}`,
|
|
158
|
+
path: ["question"],
|
|
159
|
+
});
|
|
160
|
+
}
|
|
149
161
|
const labels = new Set(value.options.map(option => option.label));
|
|
150
162
|
const contract = intentContract(value.deepInterview);
|
|
151
163
|
const review = intentReview(value.deepInterview);
|
|
@@ -459,19 +471,58 @@ function knownIntentRejection(arguments_: Record<string, unknown>): RawArgumentV
|
|
|
459
471
|
return { outcome: "reject", code: "ask-intent-contract-requires-non-empty-authority" };
|
|
460
472
|
return undefined;
|
|
461
473
|
}
|
|
474
|
+
|
|
475
|
+
function deepInterviewPlaceholderRejection(
|
|
476
|
+
arguments_: Record<string, unknown>,
|
|
477
|
+
): RawArgumentValidationResult | undefined {
|
|
478
|
+
const root = parseEncodedContainer(arguments_);
|
|
479
|
+
if (!isPlainRecord(root)) return undefined;
|
|
480
|
+
const questions = parseEncodedContainer(root.questions);
|
|
481
|
+
if (!Array.isArray(questions)) return undefined;
|
|
482
|
+
for (const [index, rawValue] of questions.entries()) {
|
|
483
|
+
const rawQuestion = parseEncodedContainer(rawValue);
|
|
484
|
+
if (!isPlainRecord(rawQuestion) || !Object.hasOwn(rawQuestion, "deepInterview")) continue;
|
|
485
|
+
const metadata = parseEncodedContainer(rawQuestion.deepInterview);
|
|
486
|
+
if (metadata !== null && metadata !== undefined && isWorkflowPlaceholderText(rawQuestion.question)) {
|
|
487
|
+
return {
|
|
488
|
+
outcome: "reject",
|
|
489
|
+
code: "ask-deep-interview-question-body-required",
|
|
490
|
+
detail: {
|
|
491
|
+
rejectedKeys: [`questions[${index}].question`],
|
|
492
|
+
hint: WORKFLOW_PLACEHOLDER_CORRECTION,
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
for (const [index, rawValue] of questions.entries()) {
|
|
498
|
+
const question = parseEncodedContainer(rawValue);
|
|
499
|
+
if (isPlainRecord(question) && isLegacyDeepInterviewPlaceholder(question.question)) {
|
|
500
|
+
return {
|
|
501
|
+
outcome: "reject",
|
|
502
|
+
code: "ask-deep-interview-question-body-required",
|
|
503
|
+
detail: { rejectedKeys: [`questions[${index}].question`], hint: WORKFLOW_PLACEHOLDER_CORRECTION },
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
|
|
462
510
|
export function recoverRoundZeroIntentContract(
|
|
463
511
|
arguments_: Record<string, unknown>,
|
|
464
512
|
stage?: "topology" | "post-topology",
|
|
465
513
|
): RawArgumentValidationResult {
|
|
514
|
+
const normalizedInput = normalizeRoundZeroOptionalNulls(arguments_);
|
|
515
|
+
const placeholderRejection = deepInterviewPlaceholderRejection(normalizedInput);
|
|
516
|
+
if (placeholderRejection) return placeholderRejection;
|
|
466
517
|
// #4649: an incomplete Round-0 topology object (deepInterview present,
|
|
467
518
|
// required fields omitted) is NOT a retired-pair recovery candidate, so it
|
|
468
519
|
// would passthrough into generic Zod validation whose error names no contract
|
|
469
520
|
// and no correction — the repeat-invalid-bisect loop. Non-candidates get the
|
|
470
521
|
// targeted correction here; candidates get it only after the stricter known
|
|
471
522
|
// intent rejections below, so every previously-covered verdict is unchanged.
|
|
472
|
-
if (!isRoundZeroRecoveryCandidate(
|
|
473
|
-
return roundZeroMetadataRejection(
|
|
474
|
-
const normalizedArguments =
|
|
523
|
+
if (!isRoundZeroRecoveryCandidate(normalizedInput))
|
|
524
|
+
return roundZeroMetadataRejection(normalizedInput, stage) ?? { outcome: "passthrough" };
|
|
525
|
+
const normalizedArguments = normalizedInput;
|
|
475
526
|
const knownRejection = knownIntentRejection(normalizedArguments);
|
|
476
527
|
if (knownRejection) return knownRejection;
|
|
477
528
|
const missingTopologyFields = roundZeroMetadataRejection(normalizedArguments, stage);
|
package/src/tools/ask.ts
CHANGED
|
@@ -114,11 +114,17 @@ const HEADLESS_CHECKBOX_CHECKED = "[x]";
|
|
|
114
114
|
const HEADLESS_CHECKBOX_UNCHECKED = "[ ]";
|
|
115
115
|
const DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS = Number.MAX_SAFE_INTEGER;
|
|
116
116
|
const DEEP_INTERVIEW_RECORDER_AWAIT_TIMEOUT_MS = 250;
|
|
117
|
+
export const MAX_EMPTY_CUSTOM_ATTEMPTS = 3;
|
|
118
|
+
const EMPTY_CUSTOM_RETRY_MESSAGE = "Custom input cannot be empty. Enter text or cancel the ask.";
|
|
117
119
|
|
|
118
120
|
function errorMessage(error: unknown): string {
|
|
119
121
|
return error instanceof Error ? error.message : String(error);
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
function nonEmptyCustomInput(value: string | undefined): string | undefined {
|
|
125
|
+
return value !== undefined && value.trim().length > 0 ? value : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
122
128
|
function isAskTimeoutError(error: unknown): boolean {
|
|
123
129
|
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === GJC_ASK_TIMEOUT_CODE;
|
|
124
130
|
}
|
|
@@ -324,7 +330,7 @@ interface UIContext {
|
|
|
324
330
|
onLeft?: () => void;
|
|
325
331
|
onRight?: () => void;
|
|
326
332
|
helpText?: string;
|
|
327
|
-
customInput?: { optionLabel: string; onSubmit: (text: string) => void };
|
|
333
|
+
customInput?: { optionLabel: string; onSubmit: (text: string) => void; allowEmpty?: boolean };
|
|
328
334
|
clarificationInput?: { optionLabel: string; onSubmit: (text: string) => void; allowEmpty?: boolean };
|
|
329
335
|
},
|
|
330
336
|
): Promise<string | undefined>;
|
|
@@ -400,6 +406,7 @@ async function askSingleQuestion(
|
|
|
400
406
|
helpText,
|
|
401
407
|
customInput: {
|
|
402
408
|
optionLabel: otherOptionLabel,
|
|
409
|
+
allowEmpty: false,
|
|
403
410
|
onSubmit: (text: string) => {
|
|
404
411
|
inlineInput = text;
|
|
405
412
|
},
|
|
@@ -541,7 +548,9 @@ async function askSingleQuestion(
|
|
|
541
548
|
timedOut = true;
|
|
542
549
|
break;
|
|
543
550
|
}
|
|
544
|
-
const input =
|
|
551
|
+
const input = nonEmptyCustomInput(
|
|
552
|
+
inlineInput !== undefined ? inlineInput : (await promptForCustomInput()).input,
|
|
553
|
+
);
|
|
545
554
|
if (input === undefined) {
|
|
546
555
|
break;
|
|
547
556
|
}
|
|
@@ -642,7 +651,9 @@ async function askSingleQuestion(
|
|
|
642
651
|
}
|
|
643
652
|
} else if (choice === otherOptionLabel) {
|
|
644
653
|
if (!selectTimedOut) {
|
|
645
|
-
const input =
|
|
654
|
+
const input = nonEmptyCustomInput(
|
|
655
|
+
inlineInput !== undefined ? inlineInput : (await promptForCustomInput()).input,
|
|
656
|
+
);
|
|
646
657
|
if (input !== undefined) {
|
|
647
658
|
customInput = input;
|
|
648
659
|
selectedOptions = [];
|
|
@@ -733,6 +744,14 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
|
|
|
733
744
|
readonly rawArgumentValidation = (arguments_: Record<string, unknown>): RawArgumentValidationResult =>
|
|
734
745
|
recoverRoundZeroIntentContract(arguments_, this.session.getDeepInterviewAskStage?.());
|
|
735
746
|
readonly strict = true;
|
|
747
|
+
/**
|
|
748
|
+
* Display-only fields: question text and option labels render to the user
|
|
749
|
+
* and carry no executable or persisted meaning, so `\uXXXX`-escaped
|
|
750
|
+
* non-ASCII text inside them degrades to a warning instead of failing the
|
|
751
|
+
* run (issue #4983). Ids, workflow-gate metadata, and deep-interview
|
|
752
|
+
* records stay load-bearing and keep the fail-closed rejection.
|
|
753
|
+
*/
|
|
754
|
+
readonly displaySafeEscapedArgFields = ["questions.question", "questions.options.label"] as const;
|
|
736
755
|
readonly loadMode = "discoverable";
|
|
737
756
|
constructor(private readonly session: ToolSession) {
|
|
738
757
|
this.description = prompt.render(askDescription);
|
|
@@ -1092,10 +1111,15 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
|
|
|
1092
1111
|
|
|
1093
1112
|
const askQuestion = async (
|
|
1094
1113
|
q: AskParams["questions"][number],
|
|
1095
|
-
options?: {
|
|
1114
|
+
options?: {
|
|
1115
|
+
previous?: QuestionResult;
|
|
1116
|
+
navigation?: NavigationControls;
|
|
1117
|
+
emptyCustomAttempt?: number;
|
|
1118
|
+
},
|
|
1096
1119
|
) => {
|
|
1097
1120
|
const rawOptionLabels = q.options.map(o => o.label);
|
|
1098
1121
|
const questionIndex = params.questions.indexOf(q);
|
|
1122
|
+
const emptyCustomAttempt = options?.emptyCustomAttempt ?? 0;
|
|
1099
1123
|
// Route headless asks through the SDK workflow-gate emitter; a connected
|
|
1100
1124
|
// SDK responder supplies the durable answer instead of an interactive UI.
|
|
1101
1125
|
if (gateEmitter && canUseWorkflowGate) {
|
|
@@ -1125,7 +1149,11 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
|
|
|
1125
1149
|
try {
|
|
1126
1150
|
const deepInterviewPrompt = formatDeepInterviewSelectorPrompt(q.question);
|
|
1127
1151
|
const isDeepInterviewQuestion = deepInterviewPrompt !== null || q.deepInterview !== undefined;
|
|
1128
|
-
const
|
|
1152
|
+
const baseDisplayQuestion = deepInterviewPrompt ?? q.question;
|
|
1153
|
+
const displayQuestion =
|
|
1154
|
+
emptyCustomAttempt > 0
|
|
1155
|
+
? `${baseDisplayQuestion}\n\n${EMPTY_CUSTOM_RETRY_MESSAGE} (attempt ${emptyCustomAttempt + 1} of ${MAX_EMPTY_CUSTOM_ATTEMPTS})`
|
|
1156
|
+
: baseDisplayQuestion;
|
|
1129
1157
|
const shouldNumberOptions = isDeepInterviewQuestion || isDeepInterviewAskQuestion(q.question);
|
|
1130
1158
|
const optionLabels = shouldNumberOptions ? numberOptionLabels(rawOptionLabels) : rawOptionLabels;
|
|
1131
1159
|
const clarificationOptionLabel = shouldNumberOptions
|
|
@@ -1248,7 +1276,27 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
|
|
|
1248
1276
|
: { kind: "resolve_without_commit", reason: "cancelled" };
|
|
1249
1277
|
await settleActiveRemote(settlement);
|
|
1250
1278
|
activeRemoteRequest = undefined;
|
|
1251
|
-
if (settlement.kind === "invalid")
|
|
1279
|
+
if (settlement.kind === "invalid") {
|
|
1280
|
+
if (emptyCustomAttempt + 1 >= MAX_EMPTY_CUSTOM_ATTEMPTS) {
|
|
1281
|
+
return {
|
|
1282
|
+
optionLabels: rawOptionLabels,
|
|
1283
|
+
selectedOptions: [] as string[],
|
|
1284
|
+
customInput: undefined,
|
|
1285
|
+
clarificationQuestion: undefined,
|
|
1286
|
+
navigation: undefined,
|
|
1287
|
+
cancelled: true,
|
|
1288
|
+
timedOut: false,
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
// A remote source may resolve synchronously. Yield to a timer queue
|
|
1292
|
+
// between retries so repeated invalid answers cannot starve the
|
|
1293
|
+
// event loop indefinitely.
|
|
1294
|
+
await Bun.sleep(1);
|
|
1295
|
+
return askQuestion(q, {
|
|
1296
|
+
...options,
|
|
1297
|
+
emptyCustomAttempt: emptyCustomAttempt + 1,
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1252
1300
|
}
|
|
1253
1301
|
activeRemoteRequest = undefined;
|
|
1254
1302
|
return {
|
package/src/utils/clipboard.ts
CHANGED
|
@@ -34,6 +34,10 @@ export class ClipboardTransportError extends Error {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export type ClipboardDeliveryOutcome =
|
|
38
|
+
| { status: "verified"; transport: "ssh" }
|
|
39
|
+
| { status: "attempted"; transport: "auto" | "native" | "osc52"; reason: string };
|
|
40
|
+
|
|
37
41
|
const SSH_HOST_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
38
42
|
const SSH_OPERATION_TIMEOUT_MS = 5000;
|
|
39
43
|
const SSH_CONNECT_TIMEOUT_S = 3;
|
|
@@ -261,41 +265,32 @@ function getClipboardTransportConfig(): { transport: "auto" | "native" | "osc52"
|
|
|
261
265
|
}
|
|
262
266
|
}
|
|
263
267
|
|
|
264
|
-
function emitOsc52(text: string):
|
|
265
|
-
if (!process.stdout.isTTY) return;
|
|
266
|
-
|
|
268
|
+
function emitOsc52(text: string): boolean {
|
|
269
|
+
if (!process.stdout.isTTY) return false;
|
|
270
|
+
let delivered = false;
|
|
271
|
+
const onError = (_err: unknown) => {
|
|
267
272
|
process.stdout.off("error", onError);
|
|
268
|
-
// Prevent unhandled 'error' from crashing the process when stdout is a closed pipe.
|
|
269
|
-
if ((err as NodeJS.ErrnoException | null | undefined)?.code === "EPIPE") {
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
273
|
};
|
|
273
274
|
try {
|
|
274
275
|
const encoded = Buffer.from(text).toString("base64");
|
|
275
276
|
const osc52 = `\x1b]52;c;${encoded}\x07`;
|
|
276
277
|
process.stdout.on("error", onError);
|
|
277
|
-
process.stdout.write(osc52,
|
|
278
|
+
process.stdout.write(osc52, _err => {
|
|
278
279
|
process.stdout.off("error", onError);
|
|
279
|
-
// If stdout is closed (e.g. piped to a process that exits early),
|
|
280
|
-
// ignore EPIPE and proceed with native clipboard best-effort.
|
|
281
|
-
if ((err as NodeJS.ErrnoException | null | undefined)?.code === "EPIPE") {
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
280
|
});
|
|
285
|
-
|
|
281
|
+
delivered = true;
|
|
282
|
+
} catch (_err) {
|
|
286
283
|
process.stdout.off("error", onError);
|
|
287
|
-
if ((err as NodeJS.ErrnoException | null | undefined)?.code !== "EPIPE") {
|
|
288
|
-
// Ignore all write failures (OSC 52 is best-effort).
|
|
289
|
-
}
|
|
290
284
|
}
|
|
285
|
+
return delivered;
|
|
291
286
|
}
|
|
292
287
|
|
|
293
|
-
async function copyToClipboardNative(text: string): Promise<
|
|
288
|
+
async function copyToClipboardNative(text: string): Promise<boolean> {
|
|
294
289
|
try {
|
|
295
290
|
if (process.env.TERMUX_VERSION) {
|
|
296
291
|
try {
|
|
297
292
|
execSync("termux-clipboard-set", { input: text, timeout: 5000 });
|
|
298
|
-
return;
|
|
293
|
+
return true;
|
|
299
294
|
} catch {
|
|
300
295
|
// Fall through to native
|
|
301
296
|
}
|
|
@@ -305,8 +300,9 @@ async function copyToClipboardNative(text: string): Promise<void> {
|
|
|
305
300
|
// unaffected by the ssh/osc52 explicit-transport contract below, which
|
|
306
301
|
// never reaches this function.
|
|
307
302
|
nativeClipboard().copyToClipboard(text);
|
|
303
|
+
return true;
|
|
308
304
|
} catch {
|
|
309
|
-
|
|
305
|
+
return false;
|
|
310
306
|
}
|
|
311
307
|
}
|
|
312
308
|
|
|
@@ -324,27 +320,40 @@ async function copyToClipboardNative(text: string): Promise<void> {
|
|
|
324
320
|
*
|
|
325
321
|
* @param text - UTF-8 text to place on the clipboard.
|
|
326
322
|
*/
|
|
327
|
-
export async function copyToClipboard(text: string): Promise<
|
|
323
|
+
export async function copyToClipboard(text: string): Promise<ClipboardDeliveryOutcome> {
|
|
328
324
|
const { transport, sshHost } = getClipboardTransportConfig();
|
|
329
325
|
|
|
330
326
|
if (transport === "ssh") {
|
|
331
327
|
await copyToClipboardViaSsh(sshHost, text);
|
|
332
|
-
return;
|
|
328
|
+
return { status: "verified", transport: "ssh" };
|
|
333
329
|
}
|
|
334
330
|
|
|
335
331
|
if (transport === "osc52") {
|
|
336
|
-
emitOsc52(text);
|
|
337
|
-
return
|
|
332
|
+
const delivered = emitOsc52(text);
|
|
333
|
+
return {
|
|
334
|
+
status: "attempted",
|
|
335
|
+
transport: "osc52",
|
|
336
|
+
reason: delivered ? "terminal accepted write; no read-back" : "terminal rejected write",
|
|
337
|
+
};
|
|
338
338
|
}
|
|
339
339
|
|
|
340
340
|
if (transport === "native") {
|
|
341
|
-
await copyToClipboardNative(text);
|
|
342
|
-
return
|
|
341
|
+
const delivered = await copyToClipboardNative(text);
|
|
342
|
+
return {
|
|
343
|
+
status: "attempted",
|
|
344
|
+
transport: "native",
|
|
345
|
+
reason: delivered ? "native API provides no read-back verification" : "native clipboard API failed",
|
|
346
|
+
};
|
|
343
347
|
}
|
|
344
348
|
|
|
345
349
|
// auto: prior best-effort dual-path behavior.
|
|
346
|
-
emitOsc52(text);
|
|
347
|
-
await copyToClipboardNative(text);
|
|
350
|
+
const osc52Delivered = emitOsc52(text);
|
|
351
|
+
const nativeDelivered = await copyToClipboardNative(text);
|
|
352
|
+
return {
|
|
353
|
+
status: "attempted",
|
|
354
|
+
transport: "auto",
|
|
355
|
+
reason: `OSC52 ${osc52Delivered ? "accepted" : "failed"}; native ${nativeDelivered ? "accepted" : "failed"}; no read-back verification`,
|
|
356
|
+
};
|
|
348
357
|
}
|
|
349
358
|
|
|
350
359
|
/**
|