@gajae-code/coding-agent 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,303 @@
1
+ import * as path from "node:path";
2
+ import type { AgentTool } from "@gajae-code/agent-core";
3
+ import { expandApplyPatchToEntries } from "../edit/modes/apply-patch";
4
+ import { LocalProtocolHandler, resolveLocalUrlToPath } from "../internal-urls/local-protocol";
5
+ import { resolveToCwd } from "../tools/path-utils";
6
+ import { ToolError } from "../tools/tool-errors";
7
+ import { listActiveSkills, readVisibleSkillActiveState, type SkillActiveEntry } from "./active-state";
8
+
9
+ export const DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE =
10
+ "Deep-interview is active; either continue interviewing with `ask`, or write/finalize the pending spec under `.gjc/specs/` / update state under `.gjc/state/`. Do not edit product code until explicit execution approval.";
11
+
12
+ const BLOCKED_TOOL_NAMES = new Set(["edit", "write", "ast_edit"]);
13
+ const ARCHIVE_OR_SQLITE_BASE_RE = /^(.+?\.(?:tar\.gz|sqlite3|sqlite|db3|zip|tgz|tar|db))(?:$|:)/i;
14
+ const INTERNAL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
15
+ const GLOB_META_RE = /[*?[\]{}]/;
16
+ const VIM_FILE_SWITCH_RE = /^\s*:(?:e|e!|edit|edit!)(?:\s+([^<\r\n]+))?(?:<CR>|\r|\n|$)/i;
17
+
18
+ type ToolWithEditMode = AgentTool & {
19
+ mode?: unknown;
20
+ customWireName?: unknown;
21
+ };
22
+
23
+ export interface DeepInterviewMutationGuardInput {
24
+ cwd: string;
25
+ sessionId?: string;
26
+ threadId?: string;
27
+ tool: ToolWithEditMode;
28
+ args: unknown;
29
+ }
30
+
31
+ interface ExtractedTargets {
32
+ paths: string[];
33
+ unknown: boolean;
34
+ }
35
+
36
+ export interface DeepInterviewMutationDecision {
37
+ blocked: boolean;
38
+ message?: string;
39
+ targets: string[];
40
+ reason?: string;
41
+ }
42
+
43
+ interface ModeState {
44
+ active?: boolean;
45
+ current_phase?: string;
46
+ session_id?: string;
47
+ thread_id?: string;
48
+ [key: string]: unknown;
49
+ }
50
+
51
+ function safeString(value: unknown): string {
52
+ return typeof value === "string" ? value : "";
53
+ }
54
+
55
+ function encodePathSegment(value: string): string {
56
+ return encodeURIComponent(value).replaceAll(".", "%2E");
57
+ }
58
+
59
+ function modeStatePath(cwd: string, skill: string, sessionId?: string): string {
60
+ const stateDir = path.join(cwd, ".gjc", "state");
61
+ const fileName = `${skill}-state.json`;
62
+ if (sessionId) return path.join(stateDir, "sessions", encodePathSegment(sessionId), fileName);
63
+ return path.join(stateDir, fileName);
64
+ }
65
+
66
+ async function readJsonFile<T>(filePath: string): Promise<T | null> {
67
+ try {
68
+ return JSON.parse(await Bun.file(filePath).text()) as T;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ async function readVisibleModeState(cwd: string, skill: string, sessionId?: string): Promise<ModeState | null> {
74
+ if (sessionId) {
75
+ const sessionState = await readJsonFile<ModeState>(modeStatePath(cwd, skill, sessionId));
76
+ if (sessionState) return sessionState;
77
+ }
78
+ return await readJsonFile<ModeState>(modeStatePath(cwd, skill));
79
+ }
80
+
81
+ function isTerminalModeState(state: ModeState | null): boolean {
82
+ if (!state || state.active !== true) return true;
83
+ const phase = String(state.current_phase ?? "")
84
+ .trim()
85
+ .toLowerCase();
86
+ return ["complete", "completed", "failed", "cancelled", "canceled", "inactive"].includes(phase);
87
+ }
88
+
89
+ function entryMatchesContext(entry: SkillActiveEntry, sessionId?: string, threadId?: string): boolean {
90
+ if (sessionId && entry.session_id && entry.session_id !== sessionId) return false;
91
+ if (threadId && entry.thread_id && entry.thread_id !== threadId) return false;
92
+ return true;
93
+ }
94
+
95
+ function modeStateMatchesContext(state: ModeState, sessionId?: string, threadId?: string): boolean {
96
+ if (sessionId && state.session_id && state.session_id !== sessionId) return false;
97
+ if (threadId && state.thread_id && state.thread_id !== threadId) return false;
98
+ return true;
99
+ }
100
+
101
+ async function isActiveDeepInterview(cwd: string, sessionId?: string, threadId?: string): Promise<boolean> {
102
+ const skillState = await readVisibleSkillActiveState(cwd, sessionId);
103
+ const activeDeepInterview = listActiveSkills(skillState).find(
104
+ entry => entry.skill === "deep-interview" && entryMatchesContext(entry, sessionId, threadId),
105
+ );
106
+ if (!activeDeepInterview) return false;
107
+
108
+ const modeState = await readVisibleModeState(cwd, "deep-interview", sessionId);
109
+ if (isTerminalModeState(modeState)) return false;
110
+ if (modeState && !modeStateMatchesContext(modeState, sessionId, threadId)) return false;
111
+ return true;
112
+ }
113
+
114
+ function normalizePosix(value: string): string {
115
+ return value.replace(/\\/g, "/");
116
+ }
117
+
118
+ function addPath(targets: ExtractedTargets, value: unknown): void {
119
+ if (typeof value === "string" && value.trim().length > 0) {
120
+ targets.paths.push(value.trim());
121
+ }
122
+ }
123
+
124
+ function getRecord(value: unknown): Record<string, unknown> | null {
125
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
126
+ }
127
+
128
+ function extractWriteTargets(args: unknown): ExtractedTargets {
129
+ const record = getRecord(args);
130
+ const targets: ExtractedTargets = { paths: [], unknown: false };
131
+ addPath(targets, record?.path);
132
+ if (targets.paths.length === 0) targets.unknown = true;
133
+ return targets;
134
+ }
135
+
136
+ function extractAstEditTargets(args: unknown): ExtractedTargets {
137
+ const record = getRecord(args);
138
+ const targets: ExtractedTargets = { paths: [], unknown: false };
139
+ const paths = record?.paths;
140
+ if (Array.isArray(paths)) {
141
+ for (const entry of paths) addPath(targets, entry);
142
+ }
143
+ if (targets.paths.length === 0) targets.unknown = true;
144
+ return targets;
145
+ }
146
+
147
+ function extractVimSwitchTargets(steps: unknown, targets: ExtractedTargets): void {
148
+ if (!Array.isArray(steps)) return;
149
+ for (const step of steps) {
150
+ const record = getRecord(step);
151
+ const keys = record?.kbd;
152
+ if (!Array.isArray(keys)) continue;
153
+ for (const key of keys) {
154
+ if (typeof key !== "string") continue;
155
+ const match = key.match(VIM_FILE_SWITCH_RE);
156
+ if (!match) continue;
157
+ const targetPath = match[1]?.trim();
158
+ if (!targetPath) {
159
+ targets.unknown = true;
160
+ continue;
161
+ }
162
+ targets.paths.push(targetPath);
163
+ }
164
+ }
165
+ }
166
+
167
+ function extractApplyPatchTargets(args: unknown, targets: ExtractedTargets): boolean {
168
+ const record = getRecord(args);
169
+ const input = record?.input;
170
+ if (typeof input !== "string") return false;
171
+ try {
172
+ for (const entry of expandApplyPatchToEntries({ input })) {
173
+ addPath(targets, entry.path);
174
+ addPath(targets, entry.rename);
175
+ }
176
+ } catch {
177
+ targets.unknown = true;
178
+ }
179
+ return true;
180
+ }
181
+
182
+ function extractEditTargets(args: unknown, tool: ToolWithEditMode): ExtractedTargets {
183
+ const record = getRecord(args);
184
+ const targets: ExtractedTargets = { paths: [], unknown: false };
185
+ const customWireName = safeString(tool.customWireName);
186
+ const mode = safeString(tool.mode);
187
+
188
+ const isApplyPatchMode = customWireName === "apply_patch" || mode === "apply_patch";
189
+ const hasApplyPatchInput = typeof record?.input === "string";
190
+ if (isApplyPatchMode || hasApplyPatchInput) {
191
+ extractApplyPatchTargets(args, targets);
192
+ if (targets.paths.length === 0) targets.unknown = true;
193
+ return targets;
194
+ }
195
+
196
+ addPath(targets, record?.path);
197
+ addPath(targets, record?.file);
198
+ const edits = record?.edits;
199
+ if (Array.isArray(edits)) {
200
+ for (const edit of edits) {
201
+ const editRecord = getRecord(edit);
202
+ addPath(targets, editRecord?.rename);
203
+ addPath(targets, editRecord?.path);
204
+ }
205
+ }
206
+ if (record?.file !== undefined || mode === "vim") {
207
+ extractVimSwitchTargets(record?.steps, targets);
208
+ }
209
+ if (targets.paths.length === 0) targets.unknown = true;
210
+ return targets;
211
+ }
212
+
213
+ function extractTargets(tool: ToolWithEditMode, args: unknown): ExtractedTargets {
214
+ if (tool.name === "write") return extractWriteTargets(args);
215
+ if (tool.name === "ast_edit") return extractAstEditTargets(args);
216
+ if (tool.name === "edit") return extractEditTargets(args, tool);
217
+ return { paths: [], unknown: true };
218
+ }
219
+
220
+ function stripSelectorBase(rawPath: string): string {
221
+ const archiveOrSqlite = rawPath.match(ARCHIVE_OR_SQLITE_BASE_RE);
222
+ if (archiveOrSqlite?.[1]) return archiveOrSqlite[1];
223
+ return rawPath;
224
+ }
225
+
226
+ function resolveRawPath(cwd: string, rawPath: string): { absolutePath?: string; unknown: boolean } {
227
+ const normalized = rawPath.trim();
228
+ if (!normalized) return { unknown: true };
229
+ if (normalized === ".") return { absolutePath: path.resolve(cwd), unknown: false };
230
+ if (normalized.startsWith("local://") || normalized.startsWith("local:/")) {
231
+ const options = LocalProtocolHandler.resolveOptions();
232
+ if (!options) return { unknown: true };
233
+ try {
234
+ return { absolutePath: resolveLocalUrlToPath(normalized, options), unknown: false };
235
+ } catch {
236
+ return { unknown: true };
237
+ }
238
+ }
239
+ if (INTERNAL_SCHEME_RE.test(normalized)) return { unknown: true };
240
+
241
+ const basePath = stripSelectorBase(normalized);
242
+ try {
243
+ return { absolutePath: resolveToCwd(basePath, cwd), unknown: false };
244
+ } catch {
245
+ return { unknown: true };
246
+ }
247
+ }
248
+
249
+ function isAllowlistedPath(cwd: string, rawPath: string): boolean {
250
+ const { absolutePath, unknown } = resolveRawPath(cwd, rawPath);
251
+ if (unknown || !absolutePath) return false;
252
+ const relative = path.relative(path.resolve(cwd), path.resolve(absolutePath));
253
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) return false;
254
+ const segments = normalizePosix(relative).split("/").filter(Boolean);
255
+ return segments[0] === ".gjc" && (segments[1] === "specs" || segments[1] === "state");
256
+ }
257
+
258
+ function allTargetsAllowlisted(cwd: string, targets: ExtractedTargets): boolean {
259
+ if (targets.unknown || targets.paths.length === 0) return false;
260
+ return targets.paths.every(rawPath => {
261
+ if (GLOB_META_RE.test(rawPath)) {
262
+ return isAllowlistedPath(cwd, rawPath);
263
+ }
264
+ return isAllowlistedPath(cwd, rawPath);
265
+ });
266
+ }
267
+
268
+ export async function assertDeepInterviewMutationRawPathsAllowed(input: {
269
+ cwd: string;
270
+ sessionId?: string;
271
+ threadId?: string;
272
+ rawPaths: string[];
273
+ }): Promise<void> {
274
+ if (!(await isActiveDeepInterview(input.cwd, input.sessionId, input.threadId))) return;
275
+ const targets: ExtractedTargets = { paths: input.rawPaths, unknown: input.rawPaths.length === 0 };
276
+ if (!allTargetsAllowlisted(input.cwd, targets)) {
277
+ throw new ToolError(DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE);
278
+ }
279
+ }
280
+
281
+ export async function getDeepInterviewMutationDecision(
282
+ input: DeepInterviewMutationGuardInput,
283
+ ): Promise<DeepInterviewMutationDecision> {
284
+ if (!BLOCKED_TOOL_NAMES.has(input.tool.name)) return { blocked: false, targets: [] };
285
+ if (!(await isActiveDeepInterview(input.cwd, input.sessionId, input.threadId))) {
286
+ return { blocked: false, targets: [] };
287
+ }
288
+ const targets = extractTargets(input.tool, input.args);
289
+ if (allTargetsAllowlisted(input.cwd, targets)) {
290
+ return { blocked: false, targets: targets.paths };
291
+ }
292
+ return {
293
+ blocked: true,
294
+ message: DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE,
295
+ targets: targets.paths,
296
+ reason: targets.unknown ? "unknown-target" : "product-target",
297
+ };
298
+ }
299
+
300
+ export async function assertDeepInterviewMutationAllowed(input: DeepInterviewMutationGuardInput): Promise<void> {
301
+ const decision = await getDeepInterviewMutationDecision(input);
302
+ if (decision.blocked) throw new ToolError(decision.message ?? DEEP_INTERVIEW_MUTATION_BLOCK_MESSAGE);
303
+ }
@@ -1,7 +1,15 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
+ import type { ThinkingLevel } from "@gajae-code/agent-core";
4
+ import { type Model, modelsAreEqual } from "@gajae-code/ai";
3
5
  import { getOAuthProviders } from "@gajae-code/ai/utils/oauth";
4
6
  import { setProjectDir } from "@gajae-code/utils";
7
+ import {
8
+ GJC_MODEL_ASSIGNMENT_TARGET_IDS,
9
+ GJC_MODEL_ASSIGNMENT_TARGETS,
10
+ type GjcModelAssignmentTargetId,
11
+ } from "../config/model-registry";
12
+ import { extractExplicitThinkingSelector, formatModelSelectorValue, parseModelPattern } from "../config/model-resolver";
5
13
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../discovery/helpers.js";
6
14
  import { resolveMemoryBackend } from "../memory-backend";
7
15
  import type { InteractiveModeContext } from "../modes/types";
@@ -11,6 +19,7 @@ import {
11
19
  formatProviderSetupResult,
12
20
  parseProviderCompatibility,
13
21
  } from "../setup/provider-onboarding";
22
+ import { parseThinkingLevel } from "../thinking";
14
23
  import { formatDuration } from "./helpers/format";
15
24
  import { commandConsumed, errorMessage, parseSlashCommand, usage } from "./helpers/parse";
16
25
  import { handleSshAcp } from "./helpers/ssh";
@@ -92,8 +101,82 @@ function providerSetupUsage(): string {
92
101
  ].join("\n");
93
102
  }
94
103
 
95
- function modelSelectionUsage(currentModelLine?: string): string {
96
- return [currentModelLine, formatModelOnboardingGuidance()]
104
+ function formatModelAssignmentSummary(runtime: SlashCommandRuntime): string {
105
+ const agentModelOverrides = runtime.settings.get("task.agentModelOverrides");
106
+ const lines = ["Model assignments:"];
107
+ for (const targetId of GJC_MODEL_ASSIGNMENT_TARGET_IDS) {
108
+ const target = GJC_MODEL_ASSIGNMENT_TARGETS[targetId];
109
+ const modelSelector =
110
+ target.settingsPath === "modelRoles" ? runtime.settings.getModelRole(targetId) : agentModelOverrides[targetId];
111
+ lines.push(` ${target.tag ?? target.id.toUpperCase()} (${target.name}): ${modelSelector ?? "(unset)"}`);
112
+ }
113
+ return lines.join("\n");
114
+ }
115
+
116
+ function parseModelCommandArgs(args: string): { targetId: GjcModelAssignmentTargetId; selector: string } {
117
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
118
+ const first = tokens[0]?.toLowerCase();
119
+ const explicitTarget = GJC_MODEL_ASSIGNMENT_TARGET_IDS.includes(first as GjcModelAssignmentTargetId)
120
+ ? (first as GjcModelAssignmentTargetId)
121
+ : undefined;
122
+ if (explicitTarget) {
123
+ return { targetId: explicitTarget, selector: tokens.slice(1).join(" ") };
124
+ }
125
+ if (first === "set") {
126
+ const second = tokens[1]?.toLowerCase();
127
+ if (GJC_MODEL_ASSIGNMENT_TARGET_IDS.includes(second as GjcModelAssignmentTargetId)) {
128
+ return { targetId: second as GjcModelAssignmentTargetId, selector: tokens.slice(2).join(" ") };
129
+ }
130
+ }
131
+ return { targetId: "default", selector: args.trim() };
132
+ }
133
+
134
+ function splitExplicitThinkingSelector(selector: string): { baseSelector: string; thinkingLevel?: ThinkingLevel } {
135
+ const trimmed = selector.trim();
136
+ const colonIndex = trimmed.lastIndexOf(":");
137
+ if (colonIndex === -1) {
138
+ return { baseSelector: trimmed };
139
+ }
140
+ const thinkingLevel = parseThinkingLevel(trimmed.slice(colonIndex + 1));
141
+ return thinkingLevel ? { baseSelector: trimmed.slice(0, colonIndex), thinkingLevel } : { baseSelector: trimmed };
142
+ }
143
+
144
+ function resolveModelCommandSelection(
145
+ runtime: SlashCommandRuntime,
146
+ selector: string,
147
+ ): { model: Model; selector: string; thinkingLevel?: ThinkingLevel } | undefined {
148
+ const availableModels = runtime.session.getAvailableModels?.() ?? [];
149
+ const matchPreferences = { usageOrder: runtime.settings.getStorage()?.getModelUsageOrder() };
150
+ const resolved = parseModelPattern(selector, availableModels, matchPreferences, {
151
+ modelRegistry: runtime.session.modelRegistry,
152
+ });
153
+ if (!resolved.model) {
154
+ return undefined;
155
+ }
156
+
157
+ const splitSelector = splitExplicitThinkingSelector(selector);
158
+ const canonicalModel = runtime.session.modelRegistry.resolveCanonicalModel?.(splitSelector.baseSelector, {
159
+ availableOnly: false,
160
+ candidates: availableModels,
161
+ });
162
+ const persistedSelector =
163
+ canonicalModel && modelsAreEqual(canonicalModel, resolved.model)
164
+ ? splitSelector.baseSelector
165
+ : `${resolved.model.provider}/${resolved.model.id}`;
166
+ return {
167
+ model: resolved.model,
168
+ selector: persistedSelector,
169
+ thinkingLevel: resolved.explicitThinkingLevel ? resolved.thinkingLevel : undefined,
170
+ };
171
+ }
172
+
173
+ function modelSelectionUsage(runtime: SlashCommandRuntime, currentModelLine?: string): string {
174
+ return [
175
+ currentModelLine,
176
+ formatModelAssignmentSummary(runtime),
177
+ "ACP/text mode: use /model <model> for DEFAULT, or /model <target> <model> for EXECUTOR, ARCHITECT, PLANNER, or CRITIC.",
178
+ formatModelOnboardingGuidance(),
179
+ ]
97
180
  .filter((line): line is string => Boolean(line))
98
181
  .join("\n\n");
99
182
  }
@@ -159,25 +242,60 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
159
242
  aliases: ["models"],
160
243
  description: "Select model (opens selector UI)",
161
244
  acpDescription: "Show current model selection",
245
+ inlineHint: "[target] <model>",
246
+ acpInputHint: "[target] <model>",
162
247
  handle: async (command, runtime) => {
163
248
  if (command.args) {
164
- const modelId = command.args.trim();
165
- const availableModels = runtime.session.getAvailableModels?.() ?? [];
166
- const match = availableModels.find(
167
- model => model.id === modelId || `${model.provider}/${model.id}` === modelId,
168
- );
169
- if (!match) {
249
+ const parsedArgs = parseModelCommandArgs(command.args);
250
+ const modelId = parsedArgs.selector;
251
+ if (!modelId) {
252
+ return usage(
253
+ modelSelectionUsage(runtime, `Missing model for ${parsedArgs.targetId.toUpperCase()}.`),
254
+ runtime,
255
+ );
256
+ }
257
+ const selection = resolveModelCommandSelection(runtime, modelId);
258
+ if (!selection) {
170
259
  return usage(
171
260
  modelSelectionUsage(
261
+ runtime,
172
262
  `Unknown model: ${modelId}. Configure or login to a provider first, then list/select models with /model.`,
173
263
  ),
174
264
  runtime,
175
265
  );
176
266
  }
177
267
  try {
178
- await runtime.session.setModel(match);
179
- await runtime.output(`Model set to ${match.provider}/${match.id}.`);
180
- await runtime.notifyTitleChanged?.();
268
+ const persistedSelector = formatModelSelectorValue(selection.selector, selection.thinkingLevel);
269
+ if (parsedArgs.targetId === "default") {
270
+ await runtime.session.setModel(selection.model, "default", {
271
+ selector: selection.selector,
272
+ thinkingLevel: selection.thinkingLevel,
273
+ });
274
+ if (selection.thinkingLevel) {
275
+ runtime.session.setThinkingLevel(selection.thinkingLevel);
276
+ }
277
+ await runtime.output(`Default model set to ${persistedSelector}.`);
278
+ await runtime.notifyTitleChanged?.();
279
+ } else {
280
+ const apiKey = await runtime.session.modelRegistry.getApiKey(
281
+ selection.model,
282
+ runtime.session.sessionId,
283
+ );
284
+ if (!apiKey) {
285
+ throw new Error(`No API key for ${selection.model.provider}/${selection.model.id}`);
286
+ }
287
+ const overrides = runtime.settings.get("task.agentModelOverrides");
288
+ const thinkingLevel =
289
+ selection.thinkingLevel ??
290
+ extractExplicitThinkingSelector(overrides[parsedArgs.targetId], runtime.settings);
291
+ const roleSelector = formatModelSelectorValue(selection.selector, thinkingLevel);
292
+ runtime.settings.set("task.agentModelOverrides", {
293
+ ...overrides,
294
+ [parsedArgs.targetId]: roleSelector,
295
+ });
296
+ runtime.settings.getStorage()?.recordModelUsage(`${selection.model.provider}/${selection.model.id}`);
297
+ await runtime.output(`${parsedArgs.targetId} agent model set to ${roleSelector}.`);
298
+ }
181
299
  await runtime.notifyConfigChanged?.();
182
300
  return commandConsumed();
183
301
  } catch (err) {
@@ -188,6 +306,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
188
306
  const model = runtime.session.model;
189
307
  await runtime.output(
190
308
  modelSelectionUsage(
309
+ runtime,
191
310
  model ? `Current model: ${model.provider}/${model.id}` : "No model is currently selected.",
192
311
  ),
193
312
  );
package/src/tools/ask.ts CHANGED
@@ -16,7 +16,16 @@
16
16
  */
17
17
 
18
18
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core";
19
- import { type Component, Container, Markdown, renderInlineMarkdown, TERMINAL, Text } from "@gajae-code/tui";
19
+ import {
20
+ type Component,
21
+ Container,
22
+ Markdown,
23
+ renderInlineMarkdown,
24
+ TERMINAL,
25
+ Text,
26
+ visibleWidth,
27
+ wrapTextWithAnsi,
28
+ } from "@gajae-code/tui";
20
29
  import { prompt, untilAborted } from "@gajae-code/utils";
21
30
  import * as z from "zod/v4";
22
31
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -598,6 +607,31 @@ function renderCustomInput(
598
607
  return text;
599
608
  }
600
609
 
610
+ interface RenderOptionListEntry {
611
+ prefix: string;
612
+ label: string;
613
+ }
614
+
615
+ class AskOptionList implements Component {
616
+ constructor(private readonly entries: RenderOptionListEntry[]) {}
617
+
618
+ render(width: number): string[] {
619
+ const lines: string[] = [];
620
+ for (const entry of this.entries) {
621
+ const prefixWidth = visibleWidth(entry.prefix);
622
+ const availableWidth = Math.max(1, width - prefixWidth);
623
+ const wrapped = wrapTextWithAnsi(entry.label, availableWidth);
624
+ const continuation = " ".repeat(prefixWidth);
625
+ for (let i = 0; i < wrapped.length; i++) {
626
+ lines.push(`${i === 0 ? entry.prefix : continuation}${wrapped[i]}`);
627
+ }
628
+ }
629
+ return lines;
630
+ }
631
+
632
+ invalidate(): void {}
633
+ }
634
+
601
635
  export const askToolRenderer = {
602
636
  renderCall(args: AskRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
603
637
  const label = formatTitle("Ask", uiTheme);
@@ -625,16 +659,18 @@ export const askToolRenderer = {
625
659
  );
626
660
  container.addChild(new Markdown(q.question, 3, 0, mdTheme, accentStyle));
627
661
 
628
- if (q.options?.length) {
629
- let optText = "";
630
- for (let j = 0; j < q.options.length; j++) {
631
- const opt = q.options[j];
632
- const isLastOpt = j === q.options.length - 1;
662
+ const qOptions = q.options;
663
+ if (qOptions?.length) {
664
+ const entries = qOptions.map((opt, j) => {
665
+ const isLastOpt = j === qOptions.length - 1;
633
666
  const optBranch = isLastOpt ? uiTheme.tree.last : uiTheme.tree.branch;
634
667
  const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
635
- optText += `\n ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
636
- }
637
- container.addChild(new Text(optText, 0, 0));
668
+ return {
669
+ prefix: ` ${uiTheme.fg("dim", continuation)} ${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} `,
670
+ label: optLabel,
671
+ };
672
+ });
673
+ container.addChild(new AskOptionList(entries));
638
674
  }
639
675
  }
640
676
  return container;
@@ -652,16 +688,18 @@ export const askToolRenderer = {
652
688
  container.addChild(new Text(`${label}${formatMeta(meta, uiTheme)}`, 0, 0));
653
689
  container.addChild(new Markdown(args.question, 1, 0, mdTheme, accentStyle));
654
690
 
655
- if (args.options?.length) {
656
- let optText = "";
657
- for (let i = 0; i < args.options.length; i++) {
658
- const opt = args.options[i];
659
- const isLast = i === args.options.length - 1;
691
+ const options = args.options;
692
+ if (options?.length) {
693
+ const entries = options.map((opt, i) => {
694
+ const isLast = i === options.length - 1;
660
695
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
661
696
  const optLabel = renderInlineMarkdown(opt.label, mdTheme, t => uiTheme.fg("muted", t));
662
- optText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} ${optLabel}`;
663
- }
664
- container.addChild(new Text(optText, 0, 0));
697
+ return {
698
+ prefix: ` ${uiTheme.fg("dim", branch)} ${uiTheme.fg("dim", uiTheme.checkbox.unchecked)} `,
699
+ label: optLabel,
700
+ };
701
+ });
702
+ container.addChild(new AskOptionList(entries));
665
703
  }
666
704
 
667
705
  return container;
@@ -9,6 +9,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
9
  import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
10
10
  import type { Theme } from "../modes/theme/theme";
11
11
  import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
12
+ import { assertDeepInterviewMutationRawPathsAllowed } from "../skill-state/deep-interview-mutation-guard";
12
13
  import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
13
14
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
14
15
  import type { ToolSession } from ".";
@@ -322,10 +323,16 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
322
323
  if (!result.applied && result.totalReplacements > 0) {
323
324
  const previewReplacementPlural = result.totalReplacements !== 1 ? "s" : "";
324
325
  const previewFilePlural = result.filesTouched !== 1 ? "s" : "";
326
+ const previewedFiles = fileList;
325
327
  queueResolveHandler(this.session, {
326
328
  label: `AST Edit: ${result.totalReplacements} replacement${previewReplacementPlural} in ${result.filesTouched} file${previewFilePlural}`,
327
329
  sourceToolName: this.name,
328
330
  apply: async (_reason: string) => {
331
+ await assertDeepInterviewMutationRawPathsAllowed({
332
+ cwd: this.session.cwd,
333
+ sessionId: this.session.getSessionId?.() ?? undefined,
334
+ rawPaths: previewedFiles,
335
+ });
329
336
  const applyResult = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, {
330
337
  rewrites: normalizedRewrites,
331
338
  dryRun: false,
package/src/tools/bash.ts CHANGED
@@ -519,6 +519,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
519
519
  },
520
520
  };
521
521
  command = await expandInternalUrls(command, { ...internalUrlOptions, ensureLocalParentDirs: true });
522
+ const sessionFile = this.session.getSessionFile?.() ?? null;
522
523
  const expandedEnv = env
523
524
  ? Object.fromEntries(
524
525
  await Promise.all(
@@ -535,7 +536,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
535
536
  : undefined;
536
537
  const resolvedEnv = {
537
538
  ...buildGjcRuntimeSessionEnv({
538
- sessionFile: this.session.getSessionFile(),
539
+ sessionFile,
539
540
  sessionId: this.session.getSessionId?.(),
540
541
  cwd: this.session.cwd,
541
542
  }),