@gajae-code/coding-agent 0.1.1 → 0.1.3
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 +16 -1
- package/dist/types/config/model-registry.d.ts +8 -0
- package/dist/types/config/model-resolver.d.ts +4 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +5 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +26 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +44 -0
- package/dist/types/goals/tools/goal-tool.d.ts +4 -4
- package/dist/types/hooks/skill-state.d.ts +3 -0
- package/dist/types/modes/components/model-selector.d.ts +5 -7
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/sdk.d.ts +2 -4
- package/dist/types/session/agent-session.d.ts +3 -9
- package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +28 -0
- package/package.json +13 -9
- package/src/config/model-registry.ts +45 -0
- package/src/config/model-resolver.ts +5 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +30 -30
- package/src/defaults/gjc/skills/team/SKILL.md +1 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +51 -21
- package/src/gjc-runtime/team-runtime.ts +80 -1
- package/src/gjc-runtime/ultragoal-guard.ts +239 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +318 -4
- package/src/goals/tools/goal-tool.ts +10 -4
- package/src/hooks/native-skill-hook.ts +26 -0
- package/src/hooks/skill-state.ts +59 -0
- package/src/main.ts +2 -17
- package/src/modes/components/model-selector.ts +225 -33
- package/src/modes/controllers/selector-controller.ts +16 -3
- package/src/modes/interactive-mode.ts +34 -22
- package/src/modes/prompt-action-autocomplete.ts +40 -15
- package/src/sdk.ts +3 -1
- package/src/session/agent-session.ts +40 -4
- package/src/setup/model-onboarding-guidance.ts +5 -3
- package/src/skill-state/deep-interview-mutation-guard.ts +303 -0
- package/src/slash-commands/builtin-registry.ts +130 -11
- package/src/tools/ask.ts +55 -17
- package/src/tools/ast-edit.ts +7 -0
- package/src/tools/bash.ts +2 -1
- package/src/tools/gh.ts +37 -9
- package/src/tools/image-gen.ts +19 -10
- package/src/tools/path-utils.ts +1 -0
|
@@ -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
|
|
96
|
-
|
|
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
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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 {
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
const
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
const
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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;
|
package/src/tools/ast-edit.ts
CHANGED
|
@@ -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
|
|
539
|
+
sessionFile,
|
|
539
540
|
sessionId: this.session.getSessionId?.(),
|
|
540
541
|
cwd: this.session.cwd,
|
|
541
542
|
}),
|