@gajae-code/coding-agent 0.8.1 → 0.8.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.
Files changed (112) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/cli/completion-cli.d.ts +41 -0
  3. package/dist/types/cli/config-cli.d.ts +1 -0
  4. package/dist/types/cli/notify-cli.d.ts +7 -0
  5. package/dist/types/cli.d.ts +124 -1
  6. package/dist/types/commands/completion.d.ts +29 -0
  7. package/dist/types/commands/config.d.ts +3 -0
  8. package/dist/types/commands/deep-interview.d.ts +3 -0
  9. package/dist/types/config/file-lock.d.ts +7 -6
  10. package/dist/types/config/keybindings.d.ts +2 -2
  11. package/dist/types/config/model-profile-activation.d.ts +6 -0
  12. package/dist/types/config/settings.d.ts +8 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  14. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  15. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +39 -0
  16. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
  17. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -1
  18. package/dist/types/gjc-runtime/deep-interview-state.d.ts +12 -0
  19. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  20. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  21. package/dist/types/main.d.ts +4 -1
  22. package/dist/types/modes/components/model-selector.d.ts +4 -0
  23. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  24. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  25. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  26. package/dist/types/modes/print-mode.d.ts +13 -1
  27. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  28. package/dist/types/notifications/config.d.ts +8 -1
  29. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  30. package/dist/types/notifications/index.d.ts +6 -3
  31. package/dist/types/notifications/lifecycle-commands.d.ts +11 -3
  32. package/dist/types/notifications/telegram-daemon.d.ts +15 -3
  33. package/dist/types/notifications/telegram-reference.d.ts +0 -5
  34. package/dist/types/runtime-api-key.d.ts +7 -0
  35. package/dist/types/session/agent-session.d.ts +10 -0
  36. package/dist/types/session/agent-storage.d.ts +1 -0
  37. package/dist/types/task/executor.d.ts +2 -0
  38. package/dist/types/tools/computer.d.ts +2 -2
  39. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  40. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  41. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  42. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  43. package/package.json +7 -7
  44. package/src/cli/completion-cli.ts +234 -0
  45. package/src/cli/config-cli.ts +37 -9
  46. package/src/cli/fast-help.ts +1 -0
  47. package/src/cli/notify-cli.ts +91 -7
  48. package/src/cli.ts +3 -1
  49. package/src/commands/completion.ts +84 -0
  50. package/src/commands/config.ts +2 -0
  51. package/src/commands/deep-interview.ts +2 -1
  52. package/src/config/file-lock.ts +73 -34
  53. package/src/config/keybindings.ts +2 -2
  54. package/src/config/model-profile-activation.ts +65 -0
  55. package/src/config/model-registry.ts +14 -2
  56. package/src/config/settings.ts +22 -0
  57. package/src/coordinator-mcp/server.ts +34 -8
  58. package/src/defaults/gjc/skills/deep-interview/SKILL.md +38 -5
  59. package/src/defaults/gjc/skills/ralplan/SKILL.md +15 -11
  60. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  61. package/src/extensibility/extensions/runner.ts +2 -0
  62. package/src/extensibility/extensions/types.ts +9 -0
  63. package/src/gjc-runtime/deep-interview-ambiguity.ts +206 -0
  64. package/src/gjc-runtime/deep-interview-recorder.ts +62 -8
  65. package/src/gjc-runtime/deep-interview-runtime.ts +210 -2
  66. package/src/gjc-runtime/deep-interview-state.ts +13 -0
  67. package/src/gjc-runtime/session-state-sidecar.ts +59 -11
  68. package/src/gjc-runtime/state-runtime.ts +12 -2
  69. package/src/gjc-runtime/team-runtime.ts +23 -0
  70. package/src/gjc-runtime/ultragoal-runtime.ts +73 -19
  71. package/src/hooks/native-skill-hook.ts +29 -0
  72. package/src/internal-urls/docs-index.generated.ts +2 -2
  73. package/src/main.ts +52 -41
  74. package/src/modes/components/model-selector.ts +54 -11
  75. package/src/modes/components/queued-message-selector.ts +92 -0
  76. package/src/modes/components/settings-selector.ts +4 -0
  77. package/src/modes/components/welcome.ts +160 -44
  78. package/src/modes/controllers/command-controller.ts +1 -2
  79. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  80. package/src/modes/controllers/input-controller.ts +179 -17
  81. package/src/modes/controllers/selector-controller.ts +122 -27
  82. package/src/modes/interactive-mode.ts +43 -16
  83. package/src/modes/print-mode.ts +43 -4
  84. package/src/modes/prompt-action-autocomplete.ts +21 -20
  85. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  86. package/src/modes/utils/ui-helpers.ts +2 -2
  87. package/src/notifications/config.ts +9 -1
  88. package/src/notifications/daemon-paths.ts +2 -0
  89. package/src/notifications/index.ts +39 -9
  90. package/src/notifications/lifecycle-commands.ts +37 -7
  91. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  92. package/src/notifications/telegram-daemon-control.ts +3 -1
  93. package/src/notifications/telegram-daemon.ts +217 -43
  94. package/src/notifications/telegram-reference.ts +0 -16
  95. package/src/prompts/system/system-prompt.md +3 -0
  96. package/src/prompts/tools/computer.md +2 -2
  97. package/src/runtime-api-key.ts +16 -0
  98. package/src/sdk.ts +20 -6
  99. package/src/session/agent-session.ts +225 -31
  100. package/src/session/agent-storage.ts +4 -0
  101. package/src/session/session-manager.ts +44 -2
  102. package/src/slash-commands/builtin-registry.ts +153 -71
  103. package/src/task/executor.ts +7 -4
  104. package/src/task/index.ts +9 -3
  105. package/src/task/output-manager.ts +16 -6
  106. package/src/task/receipt.ts +26 -7
  107. package/src/tools/computer.ts +246 -43
  108. package/src/tools/telegram-send.ts +6 -0
  109. package/src/workflow/workflow-intent-diff.ts +29 -6
  110. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  111. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  112. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -33,6 +33,7 @@ export interface ConfigCommandArgs {
33
33
  value?: string;
34
34
  flags: {
35
35
  json?: boolean;
36
+ showSecrets?: boolean;
36
37
  };
37
38
  }
38
39
  // =============================================================================
@@ -47,6 +48,19 @@ type CliSettingDef = {
47
48
  };
48
49
 
49
50
  const ALL_SETTING_PATHS = Object.keys(SETTINGS_SCHEMA) as SettingPath[];
51
+ const REDACTED_SECRET_VALUE = "<redacted>";
52
+ const SECRET_SETTING_SEGMENT_PATTERN = /(?:api[-_]?key|token|secret|password|passwd|pwd|credential)/i;
53
+
54
+ function isSecretSettingPath(path: string): boolean {
55
+ return path.split(".").some(segment => SECRET_SETTING_SEGMENT_PATTERN.test(segment));
56
+ }
57
+
58
+ function redactConfigValue(path: string, value: unknown, showSecrets?: boolean): unknown {
59
+ if (showSecrets || typeof value !== "string" || !isSecretSettingPath(path)) {
60
+ return value;
61
+ }
62
+ return REDACTED_SECRET_VALUE;
63
+ }
50
64
 
51
65
  /** Find setting definition by path */
52
66
  function findSettingDef(path: string): CliSettingDef | undefined {
@@ -105,6 +119,8 @@ export function parseConfigArgs(args: string[]): ConfigCommandArgs | undefined {
105
119
  const arg = args[i];
106
120
  if (arg === "--json") {
107
121
  result.flags.json = true;
122
+ } else if (arg === "--show-secrets") {
123
+ result.flags.showSecrets = true;
108
124
  } else if (!arg.startsWith("-")) {
109
125
  positionalArgs.push(arg);
110
126
  }
@@ -265,14 +281,15 @@ export async function runConfigCommand(cmd: ConfigCommandArgs): Promise<void> {
265
281
  }
266
282
  }
267
283
 
268
- function handleList(flags: { json?: boolean }): void {
284
+ function handleList(flags: { json?: boolean; showSecrets?: boolean }): void {
269
285
  const defs = ALL_SETTING_PATHS.map(path => findSettingDef(path)).filter((def): def is CliSettingDef => !!def);
270
286
 
271
287
  if (flags.json) {
272
288
  const result: Record<string, { value: unknown; type: string; description: string }> = {};
273
289
  for (const def of defs) {
290
+ const value = settings.get(def.path);
274
291
  result[def.path] = {
275
- value: settings.get(def.path),
292
+ value: redactConfigValue(def.path, value, flags.showSecrets),
276
293
  type: def.type,
277
294
  description: def.description,
278
295
  };
@@ -301,7 +318,8 @@ function handleList(flags: { json?: boolean }): void {
301
318
  console.log(chalk.bold.blue(`[${group}]`));
302
319
  for (const def of groups[group]) {
303
320
  const value = settings.get(def.path);
304
- const valueStr = formatValue(value);
321
+ const displayValue = redactConfigValue(def.path, value, flags.showSecrets);
322
+ const valueStr = formatValue(displayValue);
305
323
  const typeStr = getTypeDisplay(def);
306
324
  console.log(` ${chalk.white(def.path)} = ${valueStr} ${chalk.dim(typeStr)}`);
307
325
  }
@@ -309,7 +327,7 @@ function handleList(flags: { json?: boolean }): void {
309
327
  }
310
328
  }
311
329
 
312
- function handleGet(key: string | undefined, flags: { json?: boolean }): void {
330
+ function handleGet(key: string | undefined, flags: { json?: boolean; showSecrets?: boolean }): void {
313
331
  if (!key) {
314
332
  console.error(chalk.red(`Usage: ${APP_NAME} config get <key>`));
315
333
  console.error(chalk.dim(`\nRun '${APP_NAME} config list' to see available keys`));
@@ -324,16 +342,23 @@ function handleGet(key: string | undefined, flags: { json?: boolean }): void {
324
342
  }
325
343
 
326
344
  const value = settings.get(def.path);
345
+ const displayValue = redactConfigValue(def.path, value, flags.showSecrets);
327
346
 
328
347
  if (flags.json) {
329
- console.log(JSON.stringify({ key: def.path, value, type: def.type, description: def.description }, null, 2));
348
+ console.log(
349
+ JSON.stringify({ key: def.path, value: displayValue, type: def.type, description: def.description }, null, 2),
350
+ );
330
351
  return;
331
352
  }
332
353
 
333
- console.log(formatValue(value));
354
+ console.log(formatValue(displayValue));
334
355
  }
335
356
 
336
- async function handleSet(key: string | undefined, value: string | undefined, flags: { json?: boolean }): Promise<void> {
357
+ async function handleSet(
358
+ key: string | undefined,
359
+ value: string | undefined,
360
+ flags: { json?: boolean; showSecrets?: boolean },
361
+ ): Promise<void> {
337
362
  if (!key || value === undefined) {
338
363
  console.error(chalk.red(`Usage: ${APP_NAME} config set <key> <value>`));
339
364
  console.error(chalk.dim(`\nRun '${APP_NAME} config list' to see available keys`));
@@ -355,11 +380,12 @@ async function handleSet(key: string | undefined, value: string | undefined, fla
355
380
  }
356
381
 
357
382
  const newValue = settings.get(def.path);
383
+ const displayValue = redactConfigValue(def.path, newValue, flags.showSecrets);
358
384
 
359
385
  if (flags.json) {
360
- console.log(JSON.stringify({ key: def.path, value: newValue }));
386
+ console.log(JSON.stringify({ key: def.path, value: displayValue }));
361
387
  } else {
362
- console.log(chalk.green(`${theme.status.success} Set ${def.path} = ${formatValue(newValue)}`));
388
+ console.log(chalk.green(`${theme.status.success} Set ${def.path} = ${formatValue(displayValue)}`));
363
389
  }
364
390
  }
365
391
 
@@ -409,6 +435,7 @@ ${chalk.bold("Commands:")}
409
435
 
410
436
  ${chalk.bold("Options:")}
411
437
  --json Output as JSON
438
+ --show-secrets Show secret-like setting values without redaction (unsafe)
412
439
 
413
440
  ${chalk.bold("Examples:")}
414
441
  ${APP_NAME} config list
@@ -418,6 +445,7 @@ ${chalk.bold("Examples:")}
418
445
  ${APP_NAME} config set defaultThinkingLevel medium
419
446
  ${APP_NAME} config reset steeringMode
420
447
  ${APP_NAME} config list --json
448
+ ${APP_NAME} config get auth.broker.token --show-secrets
421
449
  ${APP_NAME} config init-xdg
422
450
 
423
451
  ${chalk.bold("Boolean Values:")}
@@ -15,6 +15,7 @@ export function getExtraHelpText(): string {
15
15
  ${APP_NAME} deep-interview - Run requirements interview workflow
16
16
  ${APP_NAME} skills - List/read embedded workflow skills
17
17
  ${APP_NAME} config - List, get, and set configuration values
18
+ ${APP_NAME} stats - View AI usage statistics (tokens, cost, requests)
18
19
  ${APP_NAME} notify - Send or test notifications
19
20
  ${APP_NAME} daemon - Manage background daemon helpers
20
21
  ${APP_NAME} mcp - Manage MCP server registrations
@@ -58,6 +58,11 @@ interface TelegramUser {
58
58
  allows_users_to_create_topics?: boolean;
59
59
  }
60
60
 
61
+ interface TelegramChat {
62
+ id?: number | string;
63
+ type?: string;
64
+ }
65
+
61
66
  type ThreadedModeState = "enabled" | "disabled" | "unknown";
62
67
  type ThreadedModeFinalLabel = "verified" | "unverified" | "unknown";
63
68
 
@@ -148,6 +153,7 @@ async function runSetup(deps: NotifyCommandDeps): Promise<void> {
148
153
  let chatId: string;
149
154
  if (deps.setupChatId?.trim()) {
150
155
  chatId = deps.setupChatId.trim();
156
+ await verifyPrivateChatId(fetchImpl, apiBase, token, chatId);
151
157
  process.stdout.write(`Using provided chat id ${chatId} (non-interactive).\n`);
152
158
  } else {
153
159
  const stale = await getUpdates(fetchImpl, apiBase, token, { timeout: 0, allowed_updates: ["message"] });
@@ -170,16 +176,76 @@ async function runSetup(deps: NotifyCommandDeps): Promise<void> {
170
176
  );
171
177
  }
172
178
 
173
- async function promptForToken(): Promise<string> {
174
- if (!process.stdin.isTTY) {
179
+ type TokenPromptInput = NodeJS.ReadStream & {
180
+ isRaw?: boolean;
181
+ setRawMode?: (mode: boolean) => unknown;
182
+ };
183
+
184
+ type TokenPromptOutput = Pick<NodeJS.WriteStream, "write">;
185
+
186
+ export async function promptForToken(
187
+ input: TokenPromptInput = process.stdin,
188
+ output: TokenPromptOutput = process.stdout,
189
+ ): Promise<string> {
190
+ if (!input.isTTY) {
175
191
  throw new Error("notify setup requires an interactive TTY unless setupToken is injected.");
176
192
  }
177
- const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
178
- try {
179
- return (await rl.question("Telegram BotFather token: ")).trim();
180
- } finally {
181
- rl.close();
193
+ if (typeof input.setRawMode !== "function") {
194
+ throw new Error("notify setup requires a TTY with raw input support unless setupToken is injected.");
182
195
  }
196
+
197
+ output.write("Telegram BotFather token: ");
198
+ const wasRaw = input.isRaw === true;
199
+ input.setRawMode(true);
200
+
201
+ return await new Promise<string>((resolve, reject) => {
202
+ let value = "";
203
+ let settled = false;
204
+
205
+ const cleanup = () => {
206
+ input.off("data", onData);
207
+ input.off("error", onError);
208
+ input.setRawMode?.(wasRaw);
209
+ output.write("\n");
210
+ };
211
+
212
+ const finish = (callback: () => void) => {
213
+ if (settled) return;
214
+ settled = true;
215
+ cleanup();
216
+ callback();
217
+ };
218
+
219
+ const accept = () => finish(() => resolve(value.trim()));
220
+ const cancel = () => finish(() => reject(new Error("Telegram bot token prompt cancelled.")));
221
+ const onError = (error: Error) => finish(() => reject(error));
222
+ const onData = (chunk: Buffer | string) => {
223
+ for (const char of String(chunk)) {
224
+ if (char === "\r" || char === "\n") {
225
+ accept();
226
+ return;
227
+ }
228
+ if (char === "\u0003") {
229
+ cancel();
230
+ return;
231
+ }
232
+ if (char === "\u0004") {
233
+ if (value) accept();
234
+ else cancel();
235
+ return;
236
+ }
237
+ if (char === "\u007f" || char === "\b") {
238
+ value = value.slice(0, -1);
239
+ continue;
240
+ }
241
+ if (char >= " ") value += char;
242
+ }
243
+ };
244
+
245
+ input.on("data", onData);
246
+ input.once("error", onError);
247
+ input.resume();
248
+ });
183
249
  }
184
250
 
185
251
  const THREADED_ENABLED_SUCCESS =
@@ -223,6 +289,24 @@ async function getMe(fetchImpl: typeof fetch, apiBase: string, token: string): P
223
289
  return user;
224
290
  }
225
291
 
292
+ async function verifyPrivateChatId(
293
+ fetchImpl: typeof fetch,
294
+ apiBase: string,
295
+ token: string,
296
+ chatId: string,
297
+ ): Promise<void> {
298
+ const chat = (await callTelegram<unknown>(fetchImpl, apiBase, token, "getChat", { chat_id: chatId })) as
299
+ | TelegramChat
300
+ | undefined;
301
+ if (!chat || typeof chat !== "object") {
302
+ throw new Error("Telegram getChat returned invalid Telegram response: missing valid Chat result.");
303
+ }
304
+ if (chat.type !== "private") {
305
+ const type = typeof chat.type === "string" && chat.type ? chat.type : "unknown";
306
+ throw new Error(`Provided chat id ${chatId} is a ${type} chat; pairing requires a private Telegram chat.`);
307
+ }
308
+ }
309
+
226
310
  function threadedModeState(user: TelegramUser): ThreadedModeState {
227
311
  if (user.has_topics_enabled === true) return "enabled";
228
312
  if (user.has_topics_enabled === false) return "disabled";
package/src/cli.ts CHANGED
@@ -36,6 +36,7 @@ export const commands: CommandEntry[] = [
36
36
  { name: "gc", load: () => import("./commands/gc").then(m => m.default) },
37
37
  { name: "ralplan", load: () => import("./commands/ralplan").then(m => m.default) },
38
38
  { name: "config", load: () => import("./commands/config").then(m => m.default) },
39
+ { name: "stats", load: () => import("./commands/stats").then(m => m.default) },
39
40
  { name: "notify", load: () => import("./commands/notify").then(m => m.default) },
40
41
  { name: "daemon", load: () => import("./commands/daemon").then(m => m.default) },
41
42
  { name: "web-search", aliases: ["q"], load: () => import("./commands/web-search").then(m => m.default) },
@@ -52,6 +53,7 @@ export const commands: CommandEntry[] = [
52
53
  { name: "rlm", load: () => import("./commands/rlm").then(m => m.default) },
53
54
  { name: "update", load: () => import("./commands/update").then(m => m.default) },
54
55
  { name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
56
+ { name: "completion", load: () => import("./commands/completion").then(m => m.default) },
55
57
  { name: "launch", load: () => import("./commands/launch").then(m => m.default) },
56
58
  ];
57
59
 
@@ -110,7 +112,7 @@ function hasRootVersionFlag(argv: string[]): boolean {
110
112
  return hasRootFastFlag(argv, versionFlags);
111
113
  }
112
114
 
113
- class RootHelpCommand extends Command {
115
+ export class RootHelpCommand extends Command {
114
116
  static description = "Red-claw AI coding assistant";
115
117
  static hidden = true;
116
118
  static args = {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Generate shell completion specs for external completion engines.
3
+ */
4
+ import { Args, Command, Flags } from "@gajae-code/utils/cli";
5
+ import { commands, RootHelpCommand } from "../cli";
6
+ import {
7
+ buildGjcFigSpec,
8
+ defaultInshellisenseSpecDir,
9
+ type InstallGjcInshellisenseSpecResult,
10
+ installGjcInshellisenseSpec,
11
+ renderFigSpecModule,
12
+ } from "../cli/completion-cli";
13
+
14
+ const TARGETS = ["inshellisense", "fig"] as const;
15
+
16
+ type CompletionTarget = (typeof TARGETS)[number];
17
+
18
+ function normalizeTarget(target: CompletionTarget): "inshellisense" {
19
+ return target === "fig" ? "inshellisense" : target;
20
+ }
21
+
22
+ function formatInstallResult(result: InstallGjcInshellisenseSpecResult, customDir: boolean): string {
23
+ const lines = [
24
+ "Installed GJC inshellisense completion spec:",
25
+ ` spec: ${result.specPath}`,
26
+ ` index: ${result.indexPath} (${result.indexStatus})`,
27
+ ];
28
+ if (customDir) {
29
+ lines.push(
30
+ "Add the install directory to inshellisense's [specs].path setting, then restart the inshellisense session.",
31
+ );
32
+ } else {
33
+ lines.push("Restart the inshellisense session, or run `is reinit` if your shell integration needs a refresh.");
34
+ }
35
+ return `${lines.join("\n")}\n`;
36
+ }
37
+
38
+ export default class Completion extends Command {
39
+ static description = "Generate shell completion specs";
40
+
41
+ static args = {
42
+ target: Args.string({
43
+ description: "Completion format",
44
+ required: false,
45
+ options: TARGETS,
46
+ }),
47
+ };
48
+
49
+ static flags = {
50
+ install: Flags.boolean({ description: "Install the generated spec for inshellisense" }),
51
+ dir: Flags.string({ description: "Install directory (defaults to ~/.fig/autocomplete/build)" }),
52
+ force: Flags.boolean({ description: "Overwrite an existing non-GJC inshellisense index.js" }),
53
+ json: Flags.boolean({ description: "Output JSON instead of JavaScript or status text" }),
54
+ };
55
+
56
+ async run(): Promise<void> {
57
+ const { args, flags } = await this.parse(Completion);
58
+ const target = normalizeTarget((args.target ?? "inshellisense") as CompletionTarget);
59
+ if (target !== "inshellisense") {
60
+ process.stderr.write(`Unsupported completion target: ${target}\n`);
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ if ((flags.dir || flags.force) && !flags.install) {
65
+ process.stderr.write("--dir and --force only apply with --install\n");
66
+ process.exitCode = 1;
67
+ return;
68
+ }
69
+
70
+ const spec = await buildGjcFigSpec(commands, RootHelpCommand);
71
+ if (!flags.install) {
72
+ process.stdout.write(flags.json ? `${JSON.stringify(spec, null, 2)}\n` : renderFigSpecModule(spec));
73
+ return;
74
+ }
75
+
76
+ const directory = flags.dir ?? defaultInshellisenseSpecDir();
77
+ const result = await installGjcInshellisenseSpec(spec, { dir: directory, force: flags.force });
78
+ if (flags.json) {
79
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
80
+ return;
81
+ }
82
+ process.stdout.write(formatInstallResult(result, flags.dir !== undefined));
83
+ }
84
+ }
@@ -29,6 +29,7 @@ export default class Config extends Command {
29
29
 
30
30
  static flags = {
31
31
  json: Flags.boolean({ description: "Output JSON" }),
32
+ "show-secrets": Flags.boolean({ description: "Show secret-like setting values without redaction (unsafe)" }),
32
33
  };
33
34
 
34
35
  async run(): Promise<void> {
@@ -42,6 +43,7 @@ export default class Config extends Command {
42
43
  value,
43
44
  flags: {
44
45
  json: flags.json,
46
+ showSecrets: flags["show-secrets"],
45
47
  },
46
48
  };
47
49
 
@@ -8,6 +8,7 @@ export default class DeepInterview extends Command {
8
8
  quick: Flags.boolean({ description: "Seed a quick deep-interview run" }),
9
9
  standard: Flags.boolean({ description: "Seed a standard deep-interview run" }),
10
10
  deep: Flags.boolean({ description: "Seed a deep deep-interview run" }),
11
+ trace: Flags.boolean({ description: "Run a bounded trace evidence pre-step before interview questions" }),
11
12
  threshold: Flags.string({ description: "Override ambiguity threshold for kickoff" }),
12
13
  "threshold-source": Flags.string({ description: "Describe the threshold override source" }),
13
14
  "session-id": Flags.string({
@@ -25,7 +26,7 @@ export default class DeepInterview extends Command {
25
26
  json: Flags.boolean({ description: "Output JSON" }),
26
27
  };
27
28
  static examples = [
28
- '$ gjc deep-interview --standard "<idea>"',
29
+ '$ gjc deep-interview --trace --standard "<idea>"',
29
30
  "$ gjc deep-interview --write --stage final --slug my-feature --spec ./final-spec.md",
30
31
  "$ gjc deep-interview --write --stage final --slug my-feature --spec ./final-spec.md --deliberate",
31
32
  ];
@@ -1,3 +1,4 @@
1
+ import type { Stats } from "node:fs";
1
2
  import * as fs from "node:fs/promises";
2
3
  import { isEnoent } from "@gajae-code/utils/fs-error";
3
4
 
@@ -22,9 +23,10 @@ function getLockPath(filePath: string): string {
22
23
  return `${filePath}.lock`;
23
24
  }
24
25
 
25
- async function writeLockInfo(lockPath: string): Promise<void> {
26
+ async function writeLockInfo(lockPath: string): Promise<LockInfo> {
26
27
  const info: LockInfo = { pid: process.pid, timestamp: Date.now() };
27
28
  await Bun.write(`${lockPath}/info`, JSON.stringify(info));
29
+ return info;
28
30
  }
29
31
 
30
32
  async function readLockInfo(lockPath: string): Promise<LockInfo | null> {
@@ -51,17 +53,30 @@ export interface FileLockOwnerToken {
51
53
  timestamp: number;
52
54
  }
53
55
 
54
- /** Outcome of a guarded GC removal attempt (`removeFileLockDirForGc`). */
56
+ /** Outcome of a guarded lock-dir removal attempt (`removeFileLockDirForGc`). */
55
57
  export type FileLockGcRemoval = "removed" | "owner_changed" | "missing";
56
58
 
59
+ interface LockDirStatToken {
60
+ dev: number;
61
+ ino: number;
62
+ mtimeMs: number;
63
+ ctimeMs: number;
64
+ }
65
+
66
+ type LockStaleSnapshot =
67
+ | { stale: false }
68
+ | { stale: true; owner: FileLockOwnerToken }
69
+ | { stale: true; owner: null; stat: LockDirStatToken };
70
+
57
71
  /**
58
72
  * @internal
59
- * Fail-closed removal of a dead lock dir for GC. Re-reads the on-disk owner
60
- * token as close to the unlink as possible and only deletes the dir when it
61
- * STILL holds the exact `{pid, timestamp}` identity the caller observed dead.
73
+ * Fail-closed removal of a lock dir whose owner is expected to be dead or
74
+ * finished. Re-reads the on-disk owner token as close to the unlink as possible
75
+ * and only deletes the dir when it STILL holds the exact `{pid, timestamp}`
76
+ * identity the caller observed.
62
77
  *
63
- * Closes the prune-time TOCTOU window (#606): between GC's dead re-read/probe
64
- * and the unlink, a live process can reclaim a stale lock at the same path
78
+ * Closes stale-cleanup TOCTOU windows (#606): between a dead/stale re-read and
79
+ * the unlink, a live process can reclaim a stale lock at the same path
65
80
  * (`acquireLock` rms the stale dir, then re-`mkdir`s and rewrites `info` with a
66
81
  * fresh pid+timestamp). Deleting by path alone would reap that LIVE lock. Any
67
82
  * mismatch (`owner_changed`) or absent/unreadable info (`missing` — e.g. a
@@ -99,14 +114,28 @@ function ownerLiveness(pid: number): OwnerLiveness {
99
114
  }
100
115
  }
101
116
 
102
- async function isLockStale(lockPath: string, staleMs: number): Promise<boolean> {
117
+ function statToken(stats: Stats): LockDirStatToken {
118
+ return {
119
+ dev: stats.dev,
120
+ ino: stats.ino,
121
+ mtimeMs: stats.mtimeMs,
122
+ ctimeMs: stats.ctimeMs,
123
+ };
124
+ }
125
+
126
+ function sameStatToken(a: LockDirStatToken, b: LockDirStatToken): boolean {
127
+ return a.dev === b.dev && a.ino === b.ino && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
128
+ }
129
+
130
+ async function staleLockSnapshot(lockPath: string, staleMs: number): Promise<LockStaleSnapshot> {
103
131
  const info = await readLockInfo(lockPath);
104
132
  if (!info) {
105
133
  try {
106
134
  const stats = await fs.stat(lockPath);
107
- return Date.now() - stats.mtimeMs > staleMs;
135
+ if (Date.now() - stats.mtimeMs <= staleMs) return { stale: false };
136
+ return { stale: true, owner: null, stat: statToken(stats) };
108
137
  } catch (err) {
109
- if (isEnoent(err)) return false;
138
+ if (isEnoent(err)) return { stale: false };
110
139
  throw err;
111
140
  }
112
141
  }
@@ -115,53 +144,63 @@ async function isLockStale(lockPath: string, staleMs: number): Promise<boolean>
115
144
  // not have its lock stolen (#652). Reclaim a dead owner immediately. Only when owner
116
145
  // liveness is indeterminate do we fall back to the staleMs elapsed-time heuristic.
117
146
  const liveness = ownerLiveness(info.pid);
118
- if (liveness === "dead") return true;
119
- if (liveness === "alive") return false;
120
- return Date.now() - info.timestamp > staleMs;
147
+ if (liveness === "alive") return { stale: false };
148
+ if (liveness === "dead" || Date.now() - info.timestamp > staleMs) {
149
+ return { stale: true, owner: { pid: info.pid, timestamp: info.timestamp } };
150
+ }
151
+ return { stale: false };
121
152
  }
122
153
 
123
- async function tryAcquireLock(lockPath: string): Promise<boolean> {
154
+ async function removeStaleLockForAcquire(lockPath: string, snapshot: LockStaleSnapshot): Promise<boolean> {
155
+ if (!snapshot.stale) return false;
156
+ if (snapshot.owner) {
157
+ return (await removeFileLockDirForGc(lockPath, snapshot.owner)) === "removed";
158
+ }
159
+
160
+ const currentInfo = await readLockInfo(lockPath);
161
+ if (currentInfo) return false;
124
162
  try {
125
- await fs.mkdir(lockPath);
126
- await writeLockInfo(lockPath);
163
+ const currentStats = await fs.stat(lockPath);
164
+ if (!sameStatToken(statToken(currentStats), snapshot.stat)) return false;
165
+ await fs.rm(lockPath, { recursive: true, force: true });
127
166
  return true;
167
+ } catch (err) {
168
+ if (isEnoent(err)) return false;
169
+ throw err;
170
+ }
171
+ }
172
+
173
+ async function tryAcquireLock(lockPath: string): Promise<LockInfo | null> {
174
+ try {
175
+ await fs.mkdir(lockPath);
176
+ return await writeLockInfo(lockPath);
128
177
  } catch (error) {
129
178
  if ((error as NodeJS.ErrnoException).code === "EEXIST") {
130
- return false;
179
+ return null;
131
180
  }
132
181
  throw error;
133
182
  }
134
183
  }
135
184
 
136
- async function releaseLock(lockPath: string): Promise<void> {
185
+ async function releaseLock(lockPath: string, owner: FileLockOwnerToken): Promise<void> {
137
186
  try {
138
- await fs.rm(lockPath, { recursive: true });
187
+ await removeFileLockDirForGc(lockPath, owner);
139
188
  } catch {
140
189
  // Ignore errors on release
141
190
  }
142
191
  }
143
-
144
- async function lockExists(lockPath: string): Promise<boolean> {
145
- try {
146
- await fs.stat(lockPath);
147
- return true;
148
- } catch (err) {
149
- if (isEnoent(err)) return false;
150
- throw err;
151
- }
152
- }
153
-
154
192
  async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise<void>> {
155
193
  const opts = { ...DEFAULT_OPTIONS, ...options };
156
194
  const lockPath = getLockPath(filePath);
157
195
 
158
196
  for (let attempt = 0; attempt < opts.retries; attempt++) {
159
- if (await tryAcquireLock(lockPath)) {
160
- return () => releaseLock(lockPath);
197
+ const owner = await tryAcquireLock(lockPath);
198
+ if (owner) {
199
+ return () => releaseLock(lockPath, owner);
161
200
  }
162
201
 
163
- if ((await lockExists(lockPath)) && (await isLockStale(lockPath, opts.staleMs))) {
164
- await releaseLock(lockPath);
202
+ const stale = await staleLockSnapshot(lockPath, opts.staleMs);
203
+ if (await removeStaleLockForAcquire(lockPath, stale)) {
165
204
  continue;
166
205
  }
167
206
 
@@ -134,8 +134,8 @@ export const KEYBINDINGS = {
134
134
  description: "Queue message for next turn",
135
135
  },
136
136
  "app.message.dequeue": {
137
- defaultKeys: "alt+up",
138
- description: "Dequeue message",
137
+ defaultKeys: ["alt+up", "alt+down"],
138
+ description: "Select queued message to edit",
139
139
  },
140
140
  "app.clipboard.pasteImage": {
141
141
  defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
@@ -76,6 +76,34 @@ export interface MaterializeModelProfileAssignmentOptions {
76
76
  selector: string;
77
77
  }
78
78
 
79
+ export interface MaterializeModelProfileAssignmentsOptions {
80
+ session: Pick<
81
+ ModelProfileActivationSession,
82
+ "model" | "thinkingLevel" | "setActiveModelProfile" | "getActiveModelProfile"
83
+ >;
84
+ settings: Pick<Settings, "clearOverride" | "get" | "override" | "set">;
85
+ assignments: ReadonlyMap<GjcModelAssignmentTargetId, string> | Partial<Record<GjcModelAssignmentTargetId, string>>;
86
+ }
87
+
88
+ function isReadonlyAssignmentMap(
89
+ assignments: ReadonlyMap<GjcModelAssignmentTargetId, string> | Partial<Record<GjcModelAssignmentTargetId, string>>,
90
+ ): assignments is ReadonlyMap<GjcModelAssignmentTargetId, string> {
91
+ return typeof (assignments as { entries?: unknown }).entries === "function";
92
+ }
93
+
94
+ function getMaterializedAssignments(
95
+ assignments: ReadonlyMap<GjcModelAssignmentTargetId, string> | Partial<Record<GjcModelAssignmentTargetId, string>>,
96
+ ): Array<[GjcModelAssignmentTargetId, string]> {
97
+ if (isReadonlyAssignmentMap(assignments)) return [...assignments.entries()];
98
+ const assignmentRecord: Partial<Record<GjcModelAssignmentTargetId, string>> = assignments;
99
+ const result: Array<[GjcModelAssignmentTargetId, string]> = [];
100
+ for (const role of Object.keys(assignmentRecord) as GjcModelAssignmentTargetId[]) {
101
+ const selector = assignmentRecord[role];
102
+ if (selector !== undefined) result.push([role, selector]);
103
+ }
104
+ return result;
105
+ }
106
+
79
107
  export function materializeActiveModelProfileAssignment(options: MaterializeModelProfileAssignmentOptions): boolean {
80
108
  const activeProfile = options.session.getActiveModelProfile?.() ?? options.settings.get("modelProfile.default");
81
109
  if (!activeProfile) return false;
@@ -109,6 +137,43 @@ export function materializeActiveModelProfileAssignment(options: MaterializeMode
109
137
  return true;
110
138
  }
111
139
 
140
+ export function materializeActiveModelProfileAssignments(options: MaterializeModelProfileAssignmentsOptions): boolean {
141
+ const activeProfile = options.session.getActiveModelProfile?.() ?? options.settings.get("modelProfile.default");
142
+ if (!activeProfile) return false;
143
+
144
+ const materializedAssignments = getMaterializedAssignments(options.assignments);
145
+ if (materializedAssignments.length === 0) return true;
146
+
147
+ const nextModelRoles = { ...options.settings.get("modelRoles") };
148
+ const nextAgentModelOverrides = { ...options.settings.get("task.agentModelOverrides") };
149
+ const includesDefault = materializedAssignments.some(([role]) => role === "default");
150
+
151
+ if (!includesDefault && !nextModelRoles.default && options.session.model) {
152
+ nextModelRoles.default = formatModelSelectorValue(
153
+ `${options.session.model.provider}/${options.session.model.id}`,
154
+ options.session.thinkingLevel,
155
+ );
156
+ }
157
+
158
+ for (const [role, selector] of materializedAssignments) {
159
+ const target = GJC_MODEL_ASSIGNMENT_TARGETS[role];
160
+ if (target.settingsPath === "modelRoles") {
161
+ nextModelRoles[role] = selector;
162
+ } else {
163
+ nextAgentModelOverrides[role] = selector;
164
+ }
165
+ }
166
+
167
+ options.settings.set("modelRoles", nextModelRoles);
168
+ options.settings.set("task.agentModelOverrides", nextAgentModelOverrides);
169
+ options.settings.set("modelProfile.default", undefined);
170
+ options.settings.clearOverride("modelProfile.default");
171
+ options.settings.override("modelRoles", nextModelRoles);
172
+ options.settings.override("task.agentModelOverrides", nextAgentModelOverrides);
173
+ options.session.setActiveModelProfile?.(undefined);
174
+ return true;
175
+ }
176
+
112
177
  export function formatModelProfileCredentialError(profileLabel: string, providers: readonly string[]): string {
113
178
  return `Model profile "${profileLabel}" requires credentials for: ${providers.join(", ")}. Run /login and configure the missing provider(s), then retry.`;
114
179
  }