@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
@@ -155,6 +155,23 @@ export class FallbackChainController {
155
155
  return this.advance() ? "advance" : "exhausted";
156
156
  }
157
157
 
158
+ /**
159
+ * Record one bounded escaped-argument exhaustion for the active model and
160
+ * advance immediately. The discarded wire attempts are intentionally not
161
+ * provider charges; this single policy charge prevents the same model from
162
+ * being retried forever while still allowing every eligible fallback model
163
+ * its own literal-UTF-8 recovery budget.
164
+ */
165
+ recordEscapedArgumentsFailure(reason: string, advance = true): boolean {
166
+ const selector = this.currentSelector();
167
+ if (!selector || this.exhaustedForTurn) return false;
168
+ this.#attemptStarted = false;
169
+ this.attemptsUsed = this.maxAttempts;
170
+ this.#totalAttemptsUsed += 1;
171
+ this.tried.push({ selector, triggerClass: "unknown", reason });
172
+ return advance ? this.advance() : this.activeIndex + 1 < this.chain.entries.length;
173
+ }
174
+
158
175
  advance(): boolean {
159
176
  if (this.exhaustedForTurn) return false;
160
177
  this.activeIndex += 1;
@@ -196,10 +196,10 @@ function scriptScores(text: string): Map<string, number> {
196
196
  const kana = counts.get("ja") ?? 0;
197
197
  const han = counts.get("zh") ?? 0;
198
198
  const scores = new Map<string, number>();
199
- // Korean hangul is decisive when it is at least as strong as kana; Japanese
200
- // kana claims mixed han so Chinese does not win on kanji alone. A claimant
201
- // below the evidence minimum is stray-glyph noise and must not erase
202
- // dominant han evidence.
199
+ // Korean Hangul is decisive only once it reaches the evidence minimum and is
200
+ // at least as strong as kana; an equal sub-threshold Hangul/kana pair leaves
201
+ // dominant Han evidence intact. Japanese kana claims mixed Han whenever it
202
+ // exceeds Hangul, including the lone-kana boundary below that minimum.
203
203
  const koreanClaims = hangul >= kana && hangul >= LANGUAGE_EVIDENCE_MINIMUM;
204
204
  const japaneseClaims = kana > hangul && kana >= LANGUAGE_EVIDENCE_MINIMUM;
205
205
  if (hangul > 0 && hangul >= kana) addLanguageScore(scores, "ko", hangul);
@@ -0,0 +1,366 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ ASIDE_INSTALL_COMMAND,
4
+ ASIDE_USAGE,
5
+ asideCliCandidates,
6
+ createAsideHandler,
7
+ formatAsideMcpRegistration,
8
+ parseAsideArgv,
9
+ posixQuote,
10
+ probeAsideCli,
11
+ resolveAsideCliPath,
12
+ windowsPowerShellQuote,
13
+ } from "./helpers/aside";
14
+
15
+ function runtimeHarness() {
16
+ const output: string[] = [];
17
+ return {
18
+ runtime: {
19
+ session: {},
20
+ settings: {},
21
+ cwd: "/tmp",
22
+ output: async (message: string) => {
23
+ output.push(message);
24
+ },
25
+ } as never,
26
+ output,
27
+ };
28
+ }
29
+
30
+ describe("Aside CLI probe", () => {
31
+ test("lists installer symlink then the macOS app bundle", () => {
32
+ expect(asideCliCandidates("/Users/demo")).toEqual([
33
+ "/Users/demo/.local/bin/aside",
34
+ "/Users/demo/.aside/cli/Aside CLI.app/Contents/MacOS/aside",
35
+ ]);
36
+ });
37
+
38
+ test("prefers the installer symlink over PATH", () => {
39
+ const path = resolveAsideCliPath("/Users/demo", {
40
+ isExecutable: filePath => filePath === "/Users/demo/.local/bin/aside",
41
+ which: () => "/opt/homebrew/bin/aside",
42
+ });
43
+ expect(path).toBe("/Users/demo/.local/bin/aside");
44
+ });
45
+
46
+ test("falls back to PATH when local candidates are missing", () => {
47
+ const probe = probeAsideCli("/Users/demo", {
48
+ isExecutable: filePath => filePath === "/opt/homebrew/bin/aside",
49
+ which: command => (command === "aside" ? "/opt/homebrew/bin/aside" : null),
50
+ });
51
+ expect(probe).toEqual({ ok: true, path: "/opt/homebrew/bin/aside" });
52
+ });
53
+
54
+ test("reports searched locations when nothing is executable", () => {
55
+ const probe = probeAsideCli("/Users/demo", {
56
+ isExecutable: () => false,
57
+ which: () => null,
58
+ });
59
+ expect(probe.ok).toBe(false);
60
+ if (probe.ok) throw new Error("expected a miss");
61
+ expect(probe.searched).toContain("/Users/demo/.local/bin/aside");
62
+ expect(probe.searched).toContain("PATH (aside)");
63
+ expect(probe.manualInstallCommand).toBe(ASIDE_INSTALL_COMMAND);
64
+ });
65
+ });
66
+
67
+ describe("posixQuote", () => {
68
+ test("quotes paths that contain spaces", () => {
69
+ expect(posixQuote("/Users/demo/.aside/cli/Aside CLI.app/Contents/MacOS/aside")).toBe(
70
+ "'/Users/demo/.aside/cli/Aside CLI.app/Contents/MacOS/aside'",
71
+ );
72
+ });
73
+
74
+ test("leaves simple paths unquoted", () => {
75
+ expect(posixQuote("/Users/demo/.local/bin/aside")).toBe("/Users/demo/.local/bin/aside");
76
+ });
77
+
78
+ test("quotes native Windows paths for PowerShell", () => {
79
+ expect(windowsPowerShellQuote("C:\\Users\\demo\\Aside CLI\\aside.exe")).toBe(
80
+ "'C:\\Users\\demo\\Aside CLI\\aside.exe'",
81
+ );
82
+ expect(windowsPowerShellQuote("C:\\Users\\O'Brien\\aside.exe")).toBe("'C:\\Users\\O''Brien\\aside.exe'");
83
+ });
84
+
85
+ test("selects PowerShell quoting for native Windows MCP commands", () => {
86
+ const cliPath = "C:\\Users\\demo\\Aside CLI\\aside.exe";
87
+ expect(formatAsideMcpRegistration(cliPath, "win32")).toContain(
88
+ `gjc mcp add aside ${windowsPowerShellQuote(cliPath)} mcp --project`,
89
+ );
90
+ });
91
+ });
92
+
93
+ describe("parseAsideArgv", () => {
94
+ test("preserves empty quoted arguments and escaped quotes", () => {
95
+ expect(parseAsideArgv('exec --message "" it\\\'s')).toEqual({
96
+ ok: true,
97
+ args: ["exec", "--message", "", "it's"],
98
+ });
99
+ expect(parseAsideArgv('exec "say \\"hello\\""')).toEqual({
100
+ ok: true,
101
+ args: ["exec", 'say "hello"'],
102
+ });
103
+ });
104
+
105
+ test("rejects unterminated quotes and escapes", () => {
106
+ expect(parseAsideArgv('exec "unterminated')).toEqual({ ok: false, error: "unterminated quote" });
107
+ expect(parseAsideArgv("exec trailing\\")).toEqual({ ok: false, error: "unfinished escape" });
108
+ });
109
+ });
110
+
111
+ describe("/aside slash command", () => {
112
+ test("prints usage for help verbs without probing", async () => {
113
+ const handle = createAsideHandler({
114
+ homedir: () => "/missing",
115
+ isExecutable: () => false,
116
+ which: () => null,
117
+ exec: async () => {
118
+ throw new Error("exec should not run for /aside help");
119
+ },
120
+ });
121
+ const { runtime, output } = runtimeHarness();
122
+ expect(await handle({ name: "aside", args: "help", text: "/aside help" }, runtime)).toEqual({ consumed: true });
123
+ expect(output).toEqual([ASIDE_USAGE]);
124
+ });
125
+
126
+ test("guides installation when the CLI is missing", async () => {
127
+ const handle = createAsideHandler({
128
+ homedir: () => "/Users/demo",
129
+ isExecutable: () => false,
130
+ which: () => null,
131
+ exec: async () => {
132
+ throw new Error("exec should not run when CLI is missing");
133
+ },
134
+ });
135
+ const { runtime, output } = runtimeHarness();
136
+ expect(await handle({ name: "aside", args: "", text: "/aside" }, runtime)).toEqual({ consumed: true });
137
+ expect(output[0]).toContain("Aside CLI was not found.");
138
+ expect(output[0]).toContain(ASIDE_INSTALL_COMMAND);
139
+ expect(output[0]).toContain(ASIDE_USAGE);
140
+ });
141
+
142
+ test("status prints the resolved path and version", async () => {
143
+ const execCalls: string[][] = [];
144
+ const handle = createAsideHandler({
145
+ homedir: () => "/Users/demo",
146
+ isExecutable: filePath => filePath === "/Users/demo/.local/bin/aside",
147
+ which: () => null,
148
+ exec: async (_command, args) => {
149
+ execCalls.push(args);
150
+ return { stdout: "1.26.810\n", stderr: "", code: 0, killed: false };
151
+ },
152
+ });
153
+ const { runtime, output } = runtimeHarness();
154
+ expect(await handle({ name: "aside", args: "", text: "/aside" }, runtime)).toEqual({ consumed: true });
155
+ expect(execCalls).toEqual([["--version"]]);
156
+ expect(output[0]).toContain("Aside CLI: /Users/demo/.local/bin/aside");
157
+ expect(output[0]).toContain("Version: 1.26.810");
158
+ expect(output[0]).toContain(ASIDE_USAGE);
159
+ });
160
+
161
+ test("sanitizes version output before rendering status", async () => {
162
+ const handle = createAsideHandler({
163
+ homedir: () => "/Users/demo",
164
+ isExecutable: filePath => filePath === "/Users/demo/.local/bin/aside",
165
+ which: () => null,
166
+ exec: async () => ({ stdout: "\x1b]0;pwned\x071.26.810\n", stderr: "", code: 0, killed: false }),
167
+ });
168
+ const { runtime, output } = runtimeHarness();
169
+ await handle({ name: "aside", args: "", text: "/aside" }, runtime);
170
+ expect(output[0]).toContain("Version: 1.26.810");
171
+ expect(output[0]).not.toContain("pwned");
172
+ });
173
+
174
+ test("freeform prompts run aside exec with a single prompt argument", async () => {
175
+ const execCalls: Array<{ command: string; args: string[] }> = [];
176
+ const handle = createAsideHandler({
177
+ homedir: () => "/Users/demo",
178
+ isExecutable: filePath => filePath === "/Users/demo/.local/bin/aside",
179
+ which: () => null,
180
+ exec: async (command, args) => {
181
+ execCalls.push({ command, args });
182
+ return { stdout: "ok", stderr: "", code: 0, killed: false };
183
+ },
184
+ });
185
+ const { runtime, output } = runtimeHarness();
186
+ expect(
187
+ await handle(
188
+ { name: "aside", args: "Summarize the current page", text: "/aside Summarize the current page" },
189
+ runtime,
190
+ ),
191
+ ).toEqual({ consumed: true });
192
+ expect(execCalls).toEqual([
193
+ { command: "/Users/demo/.local/bin/aside", args: ["exec", "Summarize the current page"] },
194
+ ]);
195
+ expect(output).toEqual(["ok"]);
196
+ });
197
+
198
+ test("exec passes quoted argv through without a shell", async () => {
199
+ const execCalls: string[][] = [];
200
+ const handle = createAsideHandler({
201
+ homedir: () => "/Users/demo",
202
+ isExecutable: filePath => filePath === "/Users/demo/.local/bin/aside",
203
+ which: () => null,
204
+ exec: async (_command, args) => {
205
+ execCalls.push(args);
206
+ return { stdout: "", stderr: "", code: 0, killed: false };
207
+ },
208
+ });
209
+ const { runtime, output } = runtimeHarness();
210
+ expect(
211
+ await handle(
212
+ {
213
+ name: "aside",
214
+ args: 'exec --session abc "Continue later"',
215
+ text: '/aside exec --session abc "Continue later"',
216
+ },
217
+ runtime,
218
+ ),
219
+ ).toEqual({ consumed: true });
220
+ expect(execCalls).toEqual([["exec", "--session", "abc", "Continue later"]]);
221
+ expect(output).toEqual(["Aside CLI exited 0 with no output."]);
222
+ });
223
+
224
+ test("leading flags are forwarded to aside exec", async () => {
225
+ const execCalls: string[][] = [];
226
+ const handle = createAsideHandler({
227
+ homedir: () => "/Users/demo",
228
+ isExecutable: filePath => filePath === "/Users/demo/.local/bin/aside",
229
+ which: () => null,
230
+ exec: async (_command, args) => {
231
+ execCalls.push(args);
232
+ return { stdout: "continued", stderr: "", code: 0, killed: false };
233
+ },
234
+ });
235
+ const { runtime } = runtimeHarness();
236
+ await handle({ name: "aside", args: "--session abc Continue", text: "/aside --session abc Continue" }, runtime);
237
+ expect(execCalls).toEqual([["exec", "--session", "abc", "Continue"]]);
238
+ });
239
+
240
+ test("mcp prints a quoted registration command and does not spawn stdio", async () => {
241
+ const handle = createAsideHandler({
242
+ homedir: () => "/Users/demo",
243
+ isExecutable: filePath => filePath.includes("Aside CLI.app"),
244
+ which: () => null,
245
+ exec: async () => {
246
+ throw new Error("mcp must not spawn aside mcp");
247
+ },
248
+ });
249
+ const { runtime, output } = runtimeHarness();
250
+ const cliPath = "/Users/demo/.aside/cli/Aside CLI.app/Contents/MacOS/aside";
251
+ expect(await handle({ name: "aside", args: "mcp", text: "/aside mcp" }, runtime)).toEqual({ consumed: true });
252
+ expect(output).toEqual([formatAsideMcpRegistration(cliPath)]);
253
+ expect(output[0]).toContain(`gjc mcp add aside ${posixQuote(cliPath)} mcp --project`);
254
+ expect(output[0]).not.toContain("aside mcp\n");
255
+ });
256
+
257
+ test("rejects unexpected MCP arguments without probing or spawning", async () => {
258
+ const handle = createAsideHandler({
259
+ homedir: () => "/missing",
260
+ isExecutable: () => false,
261
+ which: () => null,
262
+ exec: async () => {
263
+ throw new Error("mcp must not spawn");
264
+ },
265
+ });
266
+ const { runtime, output } = runtimeHarness();
267
+ expect(await handle({ name: "aside", args: "mcp unexpected", text: "/aside mcp unexpected" }, runtime)).toEqual({
268
+ consumed: true,
269
+ });
270
+ expect(output).toEqual(["Usage: /aside mcp"]);
271
+ });
272
+
273
+ test("repl refuses to run inside GJC", async () => {
274
+ const handle = createAsideHandler({
275
+ homedir: () => "/Users/demo",
276
+ isExecutable: filePath => filePath === "/Users/demo/.local/bin/aside",
277
+ which: () => null,
278
+ exec: async () => {
279
+ throw new Error("repl must not spawn");
280
+ },
281
+ });
282
+ const { runtime, output } = runtimeHarness();
283
+ expect(await handle({ name: "aside", args: "repl", text: "/aside repl" }, runtime)).toEqual({ consumed: true });
284
+ expect(output[0]).toContain("needs a real terminal TTY");
285
+ expect(output[0]).toContain("/Users/demo/.local/bin/aside repl");
286
+ });
287
+
288
+ test("rejects unexpected REPL arguments without probing or spawning", async () => {
289
+ const handle = createAsideHandler({
290
+ homedir: () => "/missing",
291
+ isExecutable: () => false,
292
+ which: () => null,
293
+ exec: async () => {
294
+ throw new Error("repl must not spawn");
295
+ },
296
+ });
297
+ const { runtime, output } = runtimeHarness();
298
+ expect(await handle({ name: "aside", args: "repl unexpected", text: "/aside repl unexpected" }, runtime)).toEqual(
299
+ {
300
+ consumed: true,
301
+ },
302
+ );
303
+ expect(output).toEqual(["Usage: /aside repl"]);
304
+ });
305
+
306
+ test("account forwards subcommands", async () => {
307
+ const execCalls: string[][] = [];
308
+ const handle = createAsideHandler({
309
+ homedir: () => "/Users/demo",
310
+ isExecutable: filePath => filePath === "/Users/demo/.local/bin/aside",
311
+ which: () => null,
312
+ exec: async (_command, args) => {
313
+ execCalls.push(args);
314
+ return { stdout: "u0", stderr: "", code: 0, killed: false };
315
+ },
316
+ });
317
+ const { runtime, output } = runtimeHarness();
318
+ expect(await handle({ name: "aside", args: "account list", text: "/aside account list" }, runtime)).toEqual({
319
+ consumed: true,
320
+ });
321
+ expect(execCalls).toEqual([["account", "list"]]);
322
+ expect(output).toEqual(["u0"]);
323
+ });
324
+
325
+ test("nonzero CLI exit is consumed with a non-zero status", async () => {
326
+ const handle = createAsideHandler({
327
+ homedir: () => "/Users/demo",
328
+ isExecutable: filePath => filePath === "/Users/demo/.local/bin/aside",
329
+ which: () => null,
330
+ exec: async () => ({ stdout: "no daemon", stderr: "", code: 1, killed: false }),
331
+ });
332
+ const { runtime, output } = runtimeHarness();
333
+ expect(await handle({ name: "aside", args: "account status", text: "/aside account status" }, runtime)).toEqual({
334
+ consumed: true,
335
+ exitCode: 1,
336
+ });
337
+ expect(output).toEqual(["no daemon\n(exit 1)"]);
338
+ });
339
+
340
+ test("timeout results are consumed with a non-zero status", async () => {
341
+ const handle = createAsideHandler({
342
+ homedir: () => "/Users/demo",
343
+ isExecutable: filePath => filePath === "/Users/demo/.local/bin/aside",
344
+ which: () => null,
345
+ exec: async () => ({ stdout: "partial", stderr: "", code: 0, killed: true }),
346
+ });
347
+ const { runtime, output } = runtimeHarness();
348
+ expect(await handle({ name: "aside", args: "exec Continue", text: "/aside exec Continue" }, runtime)).toEqual({
349
+ consumed: true,
350
+ exitCode: 1,
351
+ });
352
+ expect(output).toEqual(["partial\nAside CLI timed out or was cancelled."]);
353
+ });
354
+
355
+ test("sanitizes CLI control sequences before rendering output", async () => {
356
+ const handle = createAsideHandler({
357
+ homedir: () => "/Users/demo",
358
+ isExecutable: filePath => filePath === "/Users/demo/.local/bin/aside",
359
+ which: () => null,
360
+ exec: async () => ({ stdout: "\x1b]0;pwned\x07safe", stderr: "", code: 0, killed: false }),
361
+ });
362
+ const { runtime, output } = runtimeHarness();
363
+ await handle({ name: "aside", args: "account status", text: "/aside account status" }, runtime);
364
+ expect(output).toEqual(["safe"]);
365
+ });
366
+ });
@@ -45,6 +45,7 @@ import {
45
45
  } from "../setup/provider-onboarding";
46
46
  import { parseThinkingLevel } from "../thinking";
47
47
  import { getDisplayChangelogEntries } from "../utils/changelog";
48
+ import { handleAsideAcp } from "./helpers/aside";
48
49
  import { buildAutoroutingStatusReport } from "./helpers/autorouting-status";
49
50
  import { buildContextReportText } from "./helpers/context-report";
50
51
  import { switchSessionCredentialCommand } from "./helpers/credential-switch";
@@ -609,6 +610,24 @@ const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashComma
609
610
  const IMPORT_SESSION_RETAINED_DESCRIPTOR_AUTHORITY_AVAILABLE = process.platform === "linux";
610
611
 
611
612
  const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
613
+ {
614
+ name: "aside",
615
+ priority: 32,
616
+ description: "Run the Aside CLI from the composer",
617
+ acpDescription: "Run the Aside CLI",
618
+ subcommands: [
619
+ { name: "exec", description: "Run Aside with a prompt", usage: "[args] <prompt>" },
620
+ { name: "repl", description: "Aside REPL is terminal-only; prints the outside command" },
621
+ { name: "mcp", description: "Print MCP registration using the resolved CLI path" },
622
+ { name: "account", description: "Inspect or select Aside CLI accounts", usage: "[list|status|use]" },
623
+ { name: "help", description: "Show /aside usage" },
624
+ ],
625
+ inlineHint: "[exec|repl|mcp|account|help|<prompt>]",
626
+ acpInputHint: "[exec|repl|mcp|account|help|<prompt>]",
627
+ allowArgs: true,
628
+ localHeadless: true,
629
+ handle: handleAsideAcp,
630
+ },
612
631
  {
613
632
  name: "import-session",
614
633
  priority: 29,