@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.
Files changed (107) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/dist/types/config/autorouting-tier-map.d.ts +0 -9
  3. package/dist/types/config/file-lock.d.ts +7 -1
  4. package/dist/types/config/keybindings.d.ts +13 -3
  5. package/dist/types/config/model-registry.d.ts +5 -0
  6. package/dist/types/config/model-resolver.d.ts +1 -0
  7. package/dist/types/config/settings-schema.d.ts +65 -1
  8. package/dist/types/coordinator/contract.d.ts +1 -1
  9. package/dist/types/coordinator-mcp/question-state.d.ts +42 -2
  10. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  11. package/dist/types/gjc-runtime/deep-interview-state.d.ts +12 -0
  12. package/dist/types/gjc-runtime/workflow-placeholder.d.ts +6 -0
  13. package/dist/types/main.d.ts +1 -1
  14. package/dist/types/modes/action-registry.d.ts +1 -0
  15. package/dist/types/modes/components/hook-selector.d.ts +1 -0
  16. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -1
  17. package/dist/types/modes/components/welcome.d.ts +2 -0
  18. package/dist/types/modes/controllers/input-controller.d.ts +16 -1
  19. package/dist/types/modes/controllers/runtime-mcp-command-controller.d.ts +42 -0
  20. package/dist/types/modes/interactive-mode.d.ts +7 -0
  21. package/dist/types/modes/shared/oauth-url-copy.d.ts +9 -0
  22. package/dist/types/modes/types.d.ts +3 -0
  23. package/dist/types/modes/ui-language.d.ts +2 -1
  24. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  25. package/dist/types/sdk/broker/session-index.d.ts +18 -0
  26. package/dist/types/sdk/bus/index.d.ts +4 -1
  27. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
  28. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  29. package/dist/types/sdk/cli/session-cli.d.ts +1 -1
  30. package/dist/types/sdk/lifecycle/service.d.ts +32 -5
  31. package/dist/types/sdk/session.d.ts +5 -0
  32. package/dist/types/session/agent-session.d.ts +4 -0
  33. package/dist/types/session/fallback-chain-controller.d.ts +8 -0
  34. package/dist/types/slash-commands/helpers/aside.d.ts +54 -0
  35. package/dist/types/tools/ask.d.ts +9 -0
  36. package/dist/types/utils/clipboard.d.ts +9 -1
  37. package/package.json +7 -7
  38. package/scripts/generate-sdk-adapter-parity-manifest.ts +5 -2
  39. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  40. package/src/commands/sdk.ts +2 -2
  41. package/src/config/autorouting-tier-map.ts +0 -9
  42. package/src/config/file-lock.ts +15 -1
  43. package/src/config/keybindings.ts +13 -3
  44. package/src/config/model-registry.ts +132 -21
  45. package/src/config/model-resolver.ts +2 -1
  46. package/src/config/models-config-schema.ts +2 -2
  47. package/src/config/settings-schema.ts +66 -1
  48. package/src/config/settings.ts +1 -1
  49. package/src/coordinator/contract.ts +1 -0
  50. package/src/coordinator-mcp/question-state.ts +235 -4
  51. package/src/coordinator-mcp/server.ts +402 -2
  52. package/src/dap/client.ts +19 -8
  53. package/src/deep-interview/render-middleware.ts +6 -2
  54. package/src/extensibility/extensions/types.ts +2 -0
  55. package/src/gjc-runtime/deep-interview-stage.ts +20 -1
  56. package/src/gjc-runtime/deep-interview-state.ts +3 -1
  57. package/src/gjc-runtime/ralplan-runtime.ts +6 -1
  58. package/src/gjc-runtime/session-state-lock.ts +11 -2
  59. package/src/gjc-runtime/ultragoal-runtime.ts +5 -0
  60. package/src/gjc-runtime/workflow-placeholder.ts +33 -0
  61. package/src/internal-urls/docs-index.generated.ts +11 -9
  62. package/src/lsp/client.ts +39 -4
  63. package/src/main.ts +42 -11
  64. package/src/modes/action-registry.ts +7 -0
  65. package/src/modes/components/hook-selector.ts +2 -1
  66. package/src/modes/components/iterm-pet-transport.ts +4 -2
  67. package/src/modes/components/runtime-mcp-add-wizard.ts +38 -1
  68. package/src/modes/components/settings-selector.ts +1 -0
  69. package/src/modes/components/tool-status-header.ts +149 -41
  70. package/src/modes/components/welcome.ts +8 -0
  71. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  72. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  73. package/src/modes/controllers/input-controller.ts +141 -7
  74. package/src/modes/controllers/plan-mode-controller.ts +12 -4
  75. package/src/modes/controllers/runtime-mcp-command-controller.ts +164 -20
  76. package/src/modes/controllers/selector-controller.ts +11 -2
  77. package/src/modes/interactive-mode.ts +88 -1
  78. package/src/modes/shared/oauth-url-copy.ts +33 -0
  79. package/src/modes/types.ts +3 -0
  80. package/src/modes/ui-language.ts +44 -3
  81. package/src/runtime-mcp/oauth-flow.ts +19 -8
  82. package/src/runtime-mcp/transports/stdio.ts +27 -13
  83. package/src/sdk/acp/adapter.ts +29 -2
  84. package/src/sdk/broker/broker.ts +26 -6
  85. package/src/sdk/broker/lifecycle-ledger.ts +28 -1
  86. package/src/sdk/broker/lifecycle.ts +664 -5
  87. package/src/sdk/broker/session-index.ts +246 -0
  88. package/src/sdk/broker/transport.ts +1 -0
  89. package/src/sdk/bus/index.ts +135 -38
  90. package/src/sdk/bus/natives-augment.d.ts +20 -1
  91. package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
  92. package/src/sdk/bus/telegram-daemon.ts +3 -0
  93. package/src/sdk/cli/session-cli.ts +34 -7
  94. package/src/sdk/lifecycle/service.ts +166 -11
  95. package/src/sdk/mcp/server.ts +2 -1
  96. package/src/sdk/protocol/operation-inventory.generated.json +40 -0
  97. package/src/sdk/protocol/operation-registry.ts +2 -1
  98. package/src/sdk/session.ts +91 -7
  99. package/src/session/agent-session.ts +473 -66
  100. package/src/session/fallback-chain-controller.ts +17 -0
  101. package/src/setup/frictionless-onboarding.ts +4 -4
  102. package/src/slash-commands/aside-command.test.ts +366 -0
  103. package/src/slash-commands/builtin-registry.ts +19 -0
  104. package/src/slash-commands/helpers/aside.ts +299 -0
  105. package/src/tools/ask-contract.ts +54 -3
  106. package/src/tools/ask.ts +54 -6
  107. 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(arguments_))
473
- return roundZeroMetadataRejection(arguments_, stage) ?? { outcome: "passthrough" };
474
- const normalizedArguments = normalizeRoundZeroOptionalNulls(arguments_);
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 = inlineInput !== undefined ? inlineInput : (await promptForCustomInput()).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 = inlineInput !== undefined ? inlineInput : (await promptForCustomInput()).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?: { previous?: QuestionResult; navigation?: NavigationControls },
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 displayQuestion = deepInterviewPrompt ?? q.question;
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") return askQuestion(q, options);
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 {
@@ -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): void {
265
- if (!process.stdout.isTTY) return;
266
- const onError = (err: unknown) => {
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, err => {
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
- } catch (err) {
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<void> {
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
- // Ignore — native clipboard copy is best-effort
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<void> {
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
  /**