@gajae-code/coding-agent 0.11.5 → 0.11.7

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 (138) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/commands/daemon.d.ts +2 -2
  5. package/dist/types/commands/sdk.d.ts +20 -2
  6. package/dist/types/config/keybindings.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +67 -7
  8. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  9. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +5 -5
  10. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +19 -5
  12. package/dist/types/main.d.ts +24 -1
  13. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  14. package/dist/types/modes/components/model-selector.d.ts +2 -0
  15. package/dist/types/modes/components/tool-status-header.d.ts +1 -0
  16. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  17. package/dist/types/modes/interactive-mode.d.ts +2 -0
  18. package/dist/types/modes/types.d.ts +1 -0
  19. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  20. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -0
  21. package/dist/types/sdk/bus/index.d.ts +1 -1
  22. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  23. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -3
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +61 -3
  26. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  27. package/dist/types/sdk/client/discovery.d.ts +2 -0
  28. package/dist/types/sdk/client/liveness.d.ts +7 -0
  29. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  30. package/dist/types/sdk/transport/index.d.ts +12 -0
  31. package/dist/types/sdk/transport/relay.d.ts +43 -0
  32. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  33. package/dist/types/sdk/transport/socket.d.ts +7 -0
  34. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  35. package/dist/types/session/agent-session.d.ts +28 -5
  36. package/dist/types/session/btw-contract.d.ts +16 -0
  37. package/dist/types/session/internal/managed-session-scope.d.ts +21 -7
  38. package/dist/types/session/internal/managed-session-storage.d.ts +10 -8
  39. package/dist/types/session/session-manager.d.ts +35 -4
  40. package/dist/types/session/yield-queue.d.ts +8 -0
  41. package/dist/types/setup/credential-import.d.ts +5 -1
  42. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  43. package/dist/types/ssh/utils.d.ts +1 -0
  44. package/dist/types/task/discovery.d.ts +3 -1
  45. package/dist/types/tools/fetch.d.ts +5 -8
  46. package/dist/types/tools/image-gen.d.ts +16 -0
  47. package/dist/types/tools/skill-discovery.d.ts +7 -0
  48. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  49. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  50. package/dist/types/web/insane/bridge.d.ts +1 -0
  51. package/dist/types/web/insane/url-guard.d.ts +17 -0
  52. package/package.json +8 -8
  53. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  54. package/src/capability/index.ts +50 -42
  55. package/src/capability/types.ts +3 -0
  56. package/src/cli/setup-cli.ts +10 -4
  57. package/src/cli.ts +34 -9
  58. package/src/commands/daemon.ts +22 -5
  59. package/src/commands/sdk.ts +42 -9
  60. package/src/config/keybindings.ts +1 -1
  61. package/src/config/model-profiles.ts +18 -0
  62. package/src/config/settings-schema.ts +71 -7
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  64. package/src/discovery/ssh.ts +12 -1
  65. package/src/eval/py/kernel.ts +1 -21
  66. package/src/eval/py/runner-artifact.ts +84 -0
  67. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  68. package/src/gjc-runtime/ultragoal-guard.ts +88 -10
  69. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  70. package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
  71. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  72. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  73. package/src/internal-urls/docs-index.generated.ts +9 -8
  74. package/src/internal-urls/local-protocol.ts +32 -6
  75. package/src/main.ts +87 -28
  76. package/src/modes/action-registry.ts +1 -1
  77. package/src/modes/components/btw-panel.ts +82 -16
  78. package/src/modes/components/model-selector.ts +31 -3
  79. package/src/modes/components/tool-status-header.ts +6 -1
  80. package/src/modes/controllers/btw-controller.ts +119 -36
  81. package/src/modes/controllers/command-controller.ts +15 -0
  82. package/src/modes/controllers/extension-ui-controller.ts +14 -2
  83. package/src/modes/controllers/input-controller.ts +31 -0
  84. package/src/modes/controllers/selector-controller.ts +106 -12
  85. package/src/modes/interactive-mode.ts +16 -7
  86. package/src/modes/runtime-init.ts +16 -2
  87. package/src/modes/types.ts +1 -0
  88. package/src/prompts/system/btw-user.md +3 -8
  89. package/src/prompts/tools/skill-discovery.md +1 -0
  90. package/src/runtime-mcp/client.ts +41 -55
  91. package/src/runtime-mcp/content-limits.ts +71 -0
  92. package/src/runtime-mcp/json-rpc.ts +23 -9
  93. package/src/runtime-mcp/transports/http.ts +50 -16
  94. package/src/sdk/bus/chat-daemon-control.ts +20 -7
  95. package/src/sdk/bus/index.ts +8 -7
  96. package/src/sdk/bus/lifecycle-commands.ts +16 -7
  97. package/src/sdk/bus/notification-service.ts +41 -18
  98. package/src/sdk/bus/telegram-daemon-contract.ts +13 -3
  99. package/src/sdk/bus/telegram-daemon-control.ts +26 -8
  100. package/src/sdk/bus/telegram-daemon.ts +1736 -338
  101. package/src/sdk/bus/topic-registry.ts +451 -12
  102. package/src/sdk/client/discovery.ts +19 -3
  103. package/src/sdk/client/liveness.ts +49 -0
  104. package/src/sdk/host/control/dispatch.ts +14 -5
  105. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  106. package/src/sdk/session.ts +29 -5
  107. package/src/sdk/transport/auth-preface.ts +82 -0
  108. package/src/sdk/transport/index.ts +18 -0
  109. package/src/sdk/transport/relay.ts +252 -0
  110. package/src/sdk/transport/serve-cli.ts +105 -0
  111. package/src/sdk/transport/socket.ts +197 -0
  112. package/src/sdk/transport/stdio.ts +17 -0
  113. package/src/session/agent-session.ts +1212 -768
  114. package/src/session/blob-store.ts +19 -6
  115. package/src/session/btw-contract.ts +59 -0
  116. package/src/session/internal/managed-session-scope.ts +313 -57
  117. package/src/session/internal/managed-session-storage.ts +146 -35
  118. package/src/session/session-manager.ts +385 -85
  119. package/src/session/yield-queue.ts +20 -0
  120. package/src/setup/credential-import.ts +28 -3
  121. package/src/setup/provider-onboarding.ts +30 -1
  122. package/src/setup/provider-presets.json +2 -1
  123. package/src/slash-commands/builtin-registry.ts +37 -1
  124. package/src/ssh/utils.ts +14 -0
  125. package/src/task/discovery.ts +8 -2
  126. package/src/tools/ask-answer-registry.ts +15 -1
  127. package/src/tools/ask.ts +1 -1
  128. package/src/tools/fetch.ts +13 -131
  129. package/src/tools/image-gen.ts +301 -20
  130. package/src/tools/read.ts +2 -0
  131. package/src/tools/skill-discovery.ts +39 -3
  132. package/src/tools/sqlite-reader.ts +63 -5
  133. package/src/utils/pasted-image-path.ts +15 -6
  134. package/src/utils/shell-snapshot.ts +173 -63
  135. package/src/web/insane/bridge.ts +5 -1
  136. package/src/web/insane/url-guard.ts +138 -27
  137. package/src/web/scrapers/types.ts +19 -49
  138. package/src/web/scrapers/utils.ts +12 -26
@@ -1,3 +1,6 @@
1
+ import * as http from "node:http";
2
+ import * as https from "node:https";
3
+ import * as net from "node:net";
1
4
  import * as os from "node:os";
2
5
  import * as path from "node:path";
3
6
  import { getAntigravityUserAgent, getEnvApiKey, type Model } from "@gajae-code/ai";
@@ -23,13 +26,16 @@ import packageJson from "../../package.json" with { type: "json" };
23
26
  import { isAuthenticated, type ModelRegistry } from "../config/model-registry";
24
27
  import type { CustomTool } from "../extensibility/custom-tools/types";
25
28
  import imageGenDescription from "../prompts/tools/image-gen.md" with { type: "text" };
29
+ import { isPrivateOrSpecialAddress, validatePublicHttpUrl } from "../web/insane/url-guard";
26
30
  import { resolveReadPath } from "./path-utils";
27
31
 
28
32
  const DEFAULT_MODEL = "gemini-3-pro-image-preview";
29
- const DEFAULT_OPENROUTER_MODEL = "google/gemini-3-pro-image-preview";
30
- const DEFAULT_ANTIGRAVITY_MODEL = "gemini-3-pro-image";
31
33
  const IMAGE_TIMEOUT = 3 * 60 * 1000; // 3 minutes
32
34
  const MAX_IMAGE_SIZE = 35 * 1024 * 1024;
35
+ const MAX_IMAGE_REDIRECTS = 5;
36
+ const MAX_IMAGE_HEADER_SIZE = 16 * 1024;
37
+ const MAX_IMAGE_ERROR_PREVIEW_SIZE = 8 * 1024;
38
+ const IMAGE_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
33
39
  const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
34
40
  const OPENAI_IMAGE_OUTPUT_FORMAT = "webp";
35
41
  const OPENAI_IMAGE_MIME_TYPE = "image/webp";
@@ -327,6 +333,160 @@ function toDataUrl(image: InlineImageData): string {
327
333
  return `data:${image.mimeType};base64,${image.data}`;
328
334
  }
329
335
 
336
+ function withAbortSignal<T>(operation: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
337
+ if (!signal) return operation;
338
+ if (signal.aborted) return Promise.reject(signal.reason);
339
+ const deferred = Promise.withResolvers<T>();
340
+ const abort = () => deferred.reject(signal.reason);
341
+ signal.addEventListener("abort", abort, { once: true });
342
+ operation.then(deferred.resolve, deferred.reject).finally(() => signal.removeEventListener("abort", abort));
343
+ return deferred.promise;
344
+ }
345
+
346
+ function normalizePeerAddress(address: string): string {
347
+ const normalized = address.toLowerCase();
348
+ return normalized.startsWith("::ffff:") ? normalized.slice(7) : normalized;
349
+ }
350
+
351
+ function normalizeUrlHostname(hostname: string): string {
352
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
353
+ }
354
+
355
+ function responseHeaderByteLength(response: http.IncomingMessage): number {
356
+ let bytes = Buffer.byteLength(
357
+ `HTTP/${response.httpVersion} ${response.statusCode ?? ""}${response.statusMessage ? ` ${response.statusMessage}` : ""}\r\n`,
358
+ );
359
+ for (let index = 0; index < response.rawHeaders.length; index += 2) {
360
+ bytes += Buffer.byteLength(`${response.rawHeaders[index] ?? ""}: ${response.rawHeaders[index + 1] ?? ""}\r\n`);
361
+ }
362
+ return bytes + Buffer.byteLength("\r\n");
363
+ }
364
+
365
+ async function validateImageUrl(rawUrl: string, signal: AbortSignal | undefined) {
366
+ return withAbortSignal(validatePublicHttpUrl(rawUrl), signal);
367
+ }
368
+
369
+ function openImageResponse(
370
+ url: URL,
371
+ addresses: string[],
372
+ signal: AbortSignal | undefined,
373
+ ): Promise<http.IncomingMessage> {
374
+ const hostname = normalizeUrlHostname(url.hostname);
375
+ const approved = addresses.map(address => ({ address, family: net.isIP(address) }));
376
+ const approvedPeers = new Set(approved.map(record => normalizePeerAddress(record.address)));
377
+ const lookup: net.LookupFunction = (requestedHostname, options, callback) => {
378
+ const requestedFamily = options.family === "IPv4" ? 4 : options.family === "IPv6" ? 6 : (options.family ?? 0);
379
+ const matching = approved.filter(record => requestedFamily === 0 || record.family === requestedFamily);
380
+ if (normalizeUrlHostname(requestedHostname) !== hostname || matching.length === 0) {
381
+ const error = Object.assign(new Error("No approved address for image host"), { code: "ENOTFOUND" });
382
+ callback(error, options.all ? [] : "", 0);
383
+ return;
384
+ }
385
+ if (options.all) callback(null, matching);
386
+ else callback(null, matching[0].address, matching[0].family);
387
+ };
388
+ const deferred = Promise.withResolvers<http.IncomingMessage>();
389
+ const options: https.RequestOptions = {
390
+ protocol: url.protocol,
391
+ hostname,
392
+ port: url.port || undefined,
393
+ path: `${url.pathname}${url.search}`,
394
+ method: "GET",
395
+ headers: { Accept: "image/*", "Accept-Encoding": "identity", Connection: "close", Host: url.host },
396
+ agent: false,
397
+ insecureHTTPParser: false,
398
+ lookup,
399
+ maxHeaderSize: MAX_IMAGE_HEADER_SIZE,
400
+ signal,
401
+ ...(url.protocol === "https:"
402
+ ? { rejectUnauthorized: true, servername: net.isIP(hostname) === 0 ? hostname : undefined }
403
+ : {}),
404
+ };
405
+ const requestFn = url.protocol === "https:" ? https.request : http.request;
406
+ const request = requestFn(options, response => {
407
+ if (responseHeaderByteLength(response) > MAX_IMAGE_HEADER_SIZE) {
408
+ response.destroy();
409
+ deferred.reject(new Error("Image response headers exceed the maximum size of 16 KiB"));
410
+ return;
411
+ }
412
+ const peer = response.socket.remoteAddress;
413
+ if (peer && (isPrivateOrSpecialAddress(peer) || !approvedPeers.has(normalizePeerAddress(peer)))) {
414
+ response.destroy();
415
+ deferred.reject(new Error("Refusing image response from an unapproved connected peer"));
416
+ return;
417
+ }
418
+ deferred.resolve(response);
419
+ });
420
+ request.once("error", deferred.reject);
421
+ const abort = () => {
422
+ request.destroy(signal?.reason);
423
+ deferred.reject(signal?.reason);
424
+ };
425
+ if (signal?.aborted) abort();
426
+ else signal?.addEventListener("abort", abort, { once: true });
427
+ request.once("close", () => signal?.removeEventListener("abort", abort));
428
+ request.end();
429
+ return deferred.promise;
430
+ }
431
+
432
+ function validateImageResponseFraming(response: http.IncomingMessage): void {
433
+ const rawContentLengths: string[] = [];
434
+ let hasTransferEncoding = false;
435
+ for (let index = 0; index < response.rawHeaders.length; index += 2) {
436
+ const name = response.rawHeaders[index]?.toLowerCase();
437
+ if (name === "content-length") rawContentLengths.push(response.rawHeaders[index + 1] ?? "");
438
+ if (name === "transfer-encoding") hasTransferEncoding = true;
439
+ }
440
+ if (rawContentLengths.length > 1 || (rawContentLengths.length > 0 && hasTransferEncoding)) {
441
+ response.destroy();
442
+ throw new Error("Image response has ambiguous framing");
443
+ }
444
+ const contentLength = response.headers["content-length"];
445
+ if (contentLength === undefined) return;
446
+ const declaredBytes = typeof contentLength === "string" && /^\d+$/.test(contentLength) ? Number(contentLength) : NaN;
447
+ if (!Number.isSafeInteger(declaredBytes)) {
448
+ response.destroy();
449
+ throw new Error("Image response has an invalid Content-Length");
450
+ }
451
+ if (declaredBytes > MAX_IMAGE_SIZE) {
452
+ response.destroy();
453
+ throw new Error("Image response exceeds the maximum size of 35 MiB");
454
+ }
455
+ }
456
+
457
+ async function readImageResponse(
458
+ response: http.IncomingMessage,
459
+ maxBytes: number,
460
+ signal: AbortSignal | undefined,
461
+ ): Promise<Buffer> {
462
+ const abort = () => response.destroy(signal?.reason);
463
+ if (signal?.aborted) {
464
+ abort();
465
+ throw signal.reason;
466
+ }
467
+ signal?.addEventListener("abort", abort, { once: true });
468
+ const chunks: Buffer[] = [];
469
+ let receivedBytes = 0;
470
+ try {
471
+ for await (const chunk of response) {
472
+ const bytes = Buffer.from(chunk);
473
+ receivedBytes += bytes.byteLength;
474
+ if (receivedBytes > maxBytes) {
475
+ response.destroy();
476
+ throw new Error(
477
+ maxBytes === MAX_IMAGE_SIZE
478
+ ? "Image response exceeds the maximum size of 35 MiB"
479
+ : "Image download error response exceeded the preview limit",
480
+ );
481
+ }
482
+ chunks.push(bytes);
483
+ }
484
+ return Buffer.concat(chunks, receivedBytes);
485
+ } finally {
486
+ signal?.removeEventListener("abort", abort);
487
+ }
488
+ }
489
+
330
490
  async function loadImageFromUrl(imageUrl: string, signal?: AbortSignal): Promise<InlineImageData> {
331
491
  if (imageUrl.startsWith("data:")) {
332
492
  const normalized = normalizeDataUrl(imageUrl.trim());
@@ -339,17 +499,40 @@ async function loadImageFromUrl(imageUrl: string, signal?: AbortSignal): Promise
339
499
  return { data: normalized.data, mimeType: normalized.mimeType };
340
500
  }
341
501
 
342
- const response = await fetch(imageUrl, { signal });
343
- if (!response.ok) {
344
- const rawText = await response.text();
345
- throw new Error(`Image download failed (${response.status}): ${rawText}`);
346
- }
347
- const contentType = response.headers.get("content-type")?.split(";")[0];
348
- if (!contentType?.startsWith("image/")) {
349
- throw new Error(`Unsupported image type from URL: ${imageUrl}`);
502
+ let currentUrl = imageUrl;
503
+ for (let redirectCount = 0; ; redirectCount++) {
504
+ const guard = await validateImageUrl(currentUrl, signal);
505
+ if (!guard.ok) {
506
+ throw new Error(`Refusing image URL: target URL is not public HTTP(S): ${guard.reason}`);
507
+ }
508
+ const response = await openImageResponse(guard.url, guard.addresses, signal);
509
+ const status = response.statusCode ?? 0;
510
+ if (IMAGE_REDIRECT_STATUSES.has(status)) {
511
+ if (redirectCount >= MAX_IMAGE_REDIRECTS) {
512
+ response.destroy();
513
+ throw new Error("Too many redirects downloading image");
514
+ }
515
+ const location = response.headers.location;
516
+ response.destroy();
517
+ if (!location) throw new Error("Image redirect is missing a Location header");
518
+ currentUrl = new URL(location, guard.url).toString();
519
+ continue;
520
+ }
521
+ validateImageResponseFraming(response);
522
+ if (status < 200 || status >= 300) {
523
+ const preview = (await readImageResponse(response, MAX_IMAGE_ERROR_PREVIEW_SIZE, signal)).toString("utf8");
524
+ throw new Error(`Image download failed (${status}): ${preview}`);
525
+ }
526
+ const rawContentType = response.headers["content-type"];
527
+ const contentType =
528
+ typeof rawContentType === "string" ? rawContentType.split(";", 1)[0]?.trim().toLowerCase() : undefined;
529
+ if (!contentType?.startsWith("image/")) {
530
+ response.destroy();
531
+ throw new Error("Image response is missing a supported image Content-Type");
532
+ }
533
+ const buffer = await readImageResponse(response, MAX_IMAGE_SIZE, signal);
534
+ return { data: buffer.toBase64(), mimeType: contentType };
350
535
  }
351
- const buffer = await response.bytes();
352
- return { data: buffer.toBase64(), mimeType: contentType };
353
536
  }
354
537
 
355
538
  function collectOpenRouterResponseText(message: OpenRouterMessage | undefined): string | undefined {
@@ -399,6 +582,49 @@ export function setPreferredImageProvider(provider: ImageProvider | "auto"): voi
399
582
  preferredImageProvider = provider;
400
583
  }
401
584
 
585
+ /** Provider → default image model mapping for auto-binding */
586
+ export const IMAGE_PROVIDER_DEFAULTS: Record<string, string> = {
587
+ openai: "gpt-image-2",
588
+ "openai-codex": "gpt-image-2",
589
+ antigravity: "gemini-3-pro-image",
590
+ gemini: "gemini-3-pro-image-preview",
591
+ openrouter: "google/gemini-3-pro-image-preview",
592
+ };
593
+
594
+ /** Resolved image generation configuration from settings */
595
+ export interface ImageProviderConfig {
596
+ provider: ImageProvider | "auto" | "custom";
597
+ model: string | null;
598
+ customUrl?: string;
599
+ customKey?: string;
600
+ customKeyEnv?: string;
601
+ }
602
+
603
+ /** Module-level configured image model state (set from settings at session init) */
604
+ let configuredImageConfig: ImageProviderConfig | null = null;
605
+
606
+ /** Set the configured image provider + model from settings */
607
+ export function setConfiguredImageModel(config: ImageProviderConfig | null): void {
608
+ configuredImageConfig = config;
609
+ // Keep preferredImageProvider in sync for backward compat
610
+ if (config && config.provider !== "auto" && config.provider !== "custom") {
611
+ preferredImageProvider = config.provider;
612
+ } else if (!config || config.provider === "auto") {
613
+ preferredImageProvider = "auto";
614
+ }
615
+ }
616
+
617
+ /** Get the current configured image model (for UI display) */
618
+ export function getConfiguredImageModel(): ImageProviderConfig | null {
619
+ return configuredImageConfig;
620
+ }
621
+
622
+ /** Resolve the effective image model for a configured provider */
623
+ export function resolveImageModel(provider: string, modelOverride: string | null): string {
624
+ if (modelOverride) return modelOverride;
625
+ return IMAGE_PROVIDER_DEFAULTS[provider] ?? DEFAULT_MODEL;
626
+ }
627
+
402
628
  interface ParsedAntigravityCredentials {
403
629
  accessToken: string;
404
630
  projectId?: string;
@@ -420,6 +646,22 @@ function parseAntigravityCredentials(raw: string): ParsedAntigravityCredentials
420
646
  return rawToken.length > 0 ? { accessToken: rawToken } : null;
421
647
  }
422
648
 
649
+ function createCustomImageModel(baseUrl: string, id: string): Model<"openai-responses"> {
650
+ return {
651
+ id,
652
+ name: id,
653
+ api: "openai-responses",
654
+ provider: "openai",
655
+ baseUrl: baseUrl.replace(/\/+$/, ""),
656
+ reasoning: false,
657
+ input: ["text"],
658
+ output: ["text", "image"],
659
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
660
+ contextWindow: 128_000,
661
+ maxTokens: 16_384,
662
+ };
663
+ }
664
+
423
665
  async function findAntigravityCredentials(
424
666
  modelRegistry: ModelRegistry,
425
667
  sessionId?: string,
@@ -467,7 +709,44 @@ async function findImageApiKey(
467
709
  activeModel?: Model,
468
710
  sessionId?: string,
469
711
  ): Promise<ImageApiKey | null> {
470
- // If a specific provider is preferred, try it first.
712
+ // Config-driven routing: if an explicit provider+model is configured, use it.
713
+ if (configuredImageConfig && configuredImageConfig.provider !== "auto") {
714
+ const config = configuredImageConfig;
715
+ if (config.provider === "custom") {
716
+ const baseUrl = config.customUrl?.trim();
717
+ const apiKey = config.customKey ?? (config.customKeyEnv ? Bun.env[config.customKeyEnv] : undefined);
718
+ if (baseUrl && apiKey) {
719
+ return {
720
+ provider: "openai",
721
+ apiKey,
722
+ model: createCustomImageModel(baseUrl, config.model ?? IMAGE_PROVIDER_DEFAULTS.openai),
723
+ };
724
+ }
725
+ return null;
726
+ }
727
+ // For configured providers, resolve credentials through the existing paths.
728
+ if (config.provider === "openai" || config.provider === "openai-codex") {
729
+ const openAI = await findOpenAIHostedImageCredentials(modelRegistry, activeModel, sessionId);
730
+ if (openAI) return openAI;
731
+ return null;
732
+ }
733
+ if (config.provider === "antigravity") {
734
+ if (!modelRegistry) return null;
735
+ return await findAntigravityCredentials(modelRegistry, sessionId);
736
+ }
737
+ if (config.provider === "gemini") {
738
+ const geminiKey = getEnvApiKey("google");
739
+ if (geminiKey) return { provider: "gemini", apiKey: geminiKey };
740
+ const googleKey = $env.GOOGLE_API_KEY;
741
+ return googleKey ? { provider: "gemini", apiKey: googleKey } : null;
742
+ }
743
+ if (config.provider === "openrouter") {
744
+ const openRouterKey = getEnvApiKey("openrouter");
745
+ return openRouterKey ? { provider: "openrouter", apiKey: openRouterKey } : null;
746
+ }
747
+ }
748
+
749
+ // If a specific provider is preferred (legacy path), try it first.
471
750
  if (preferredImageProvider === "openai") {
472
751
  const openAI = await findOpenAIHostedImageCredentials(modelRegistry, activeModel, sessionId);
473
752
  if (openAI) return openAI;
@@ -945,13 +1224,15 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
945
1224
 
946
1225
  const provider = apiKey.provider;
947
1226
  const model =
948
- provider === "openai" || provider === "openai-codex"
949
- ? (apiKey.model?.id ?? "gpt")
950
- : provider === "antigravity"
951
- ? DEFAULT_ANTIGRAVITY_MODEL
952
- : provider === "openrouter"
953
- ? DEFAULT_OPENROUTER_MODEL
954
- : DEFAULT_MODEL;
1227
+ configuredImageConfig && configuredImageConfig.provider !== "auto" && configuredImageConfig.model
1228
+ ? configuredImageConfig.model
1229
+ : provider === "openai" || provider === "openai-codex"
1230
+ ? (apiKey.model?.id ?? resolveImageModel(provider, null))
1231
+ : provider === "antigravity"
1232
+ ? resolveImageModel("antigravity", null)
1233
+ : provider === "openrouter"
1234
+ ? resolveImageModel("openrouter", null)
1235
+ : resolveImageModel("gemini", null);
955
1236
  const resolvedModel = provider === "openrouter" ? resolveOpenRouterModel(model) : model;
956
1237
  const cwd = ctx.sessionManager.getCwd();
957
1238
 
package/src/tools/read.ts CHANGED
@@ -73,6 +73,7 @@ import {
73
73
  } from "./path-utils";
74
74
  import { formatBytes, replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
75
75
  import {
76
+ enforceSqliteQueryOnly,
76
77
  executeReadQuery,
77
78
  getRowByKey,
78
79
  getRowByRowId,
@@ -1272,6 +1273,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1272
1273
  let db: Database | null = null;
1273
1274
  try {
1274
1275
  db = new Database(resolvedSqlitePath.absolutePath, { readonly: true, strict: true });
1276
+ enforceSqliteQueryOnly(db);
1275
1277
  db.run("PRAGMA busy_timeout = 3000");
1276
1278
  throwIfAborted(signal);
1277
1279
 
@@ -21,6 +21,13 @@ export type SkillDiscoveryToolInput = z.infer<typeof skillDiscoverySchema>;
21
21
  export interface SkillDiscoveryToolDetails {
22
22
  candidates: RuntimeSkillDiscoveryCandidate[];
23
23
  count: number;
24
+ /**
25
+ * Present only when zero candidates were returned AND discovery config gates
26
+ * (`skills.enabled` / `skills.enablePiProject` / `skills.enablePiUser`)
27
+ * prevented some or all of the requested scope from being searched. Without
28
+ * this, a disabled config is indistinguishable from "no skills exist".
29
+ */
30
+ notice?: string;
24
31
  }
25
32
 
26
33
  export class SkillDiscoveryTool implements AgentTool<typeof skillDiscoverySchema, SkillDiscoveryToolDetails> {
@@ -57,17 +64,46 @@ export class SkillDiscoveryTool implements AgentTool<typeof skillDiscoverySchema
57
64
  signal?: AbortSignal,
58
65
  ): Promise<AgentToolResult<SkillDiscoveryToolDetails>> {
59
66
  return untilAborted(signal, async () => {
67
+ const source = input.source ?? "all";
60
68
  const candidates = await discoverRuntimeSkills({
61
69
  cwd: this.#session.cwd,
62
70
  query: input.query,
63
- source: input.source ?? "all",
71
+ source,
64
72
  limit: input.limit,
65
73
  policy: this.#getRuntimeSkillPolicy(),
66
74
  });
75
+ const details: SkillDiscoveryToolDetails = { candidates, count: candidates.length };
76
+ if (candidates.length === 0) {
77
+ const notice = this.#disabledPolicyNotice(source);
78
+ if (notice) details.notice = notice;
79
+ }
67
80
  return {
68
- content: [{ type: "text", text: JSON.stringify({ candidates, count: candidates.length }, null, 2) }],
69
- details: { candidates, count: candidates.length },
81
+ content: [{ type: "text", text: JSON.stringify(details, null, 2) }],
82
+ details,
70
83
  };
71
84
  });
72
85
  }
86
+
87
+ /**
88
+ * Explain an empty result that is caused by disabled discovery config rather
89
+ * than by an actually empty skill catalog.
90
+ */
91
+ #disabledPolicyNotice(source: "all" | "project" | "user"): string | undefined {
92
+ const policy = this.#getRuntimeSkillPolicy();
93
+ if (policy.enabled !== true) {
94
+ return "Runtime skill discovery is disabled: `skills.enabled` is false, so no skill directories were searched. Enable it with `gjc config set skills.enabled true` (project and user scopes additionally require `skills.enablePiProject` / `skills.enablePiUser`).";
95
+ }
96
+ const skipped: string[] = [];
97
+ const commands: string[] = [];
98
+ if ((source === "all" || source === "project") && policy.enablePiProject !== true) {
99
+ skipped.push("project (`skills.enablePiProject` is false)");
100
+ commands.push("`gjc config set skills.enablePiProject true`");
101
+ }
102
+ if ((source === "all" || source === "user") && policy.enablePiUser !== true) {
103
+ skipped.push("user (`skills.enablePiUser` is false)");
104
+ commands.push("`gjc config set skills.enablePiUser true`");
105
+ }
106
+ if (skipped.length === 0) return undefined;
107
+ return `Skill discovery skipped disabled scope(s): ${skipped.join(", ")}. Enable them with ${commands.join(" and ")}.`;
108
+ }
73
109
  }
@@ -272,6 +272,7 @@ const COMMENT_OR_TERMINATOR_ERROR =
272
272
  "SQLite 'where' clause must not contain comments or statement terminators; use '?q=SELECT ...' for raw SQL";
273
273
  const FORBIDDEN_KEYWORD_ERROR =
274
274
  "SQLite 'where' clause must not contain LIMIT/OFFSET/UNION/INTERSECT/EXCEPT/ATTACH/DETACH/PRAGMA; use '?q=SELECT ...' for raw SQL";
275
+ const RAW_QUERY_ERROR = "SQLite raw queries must contain exactly one explicit SELECT statement";
275
276
 
276
277
  /**
277
278
  * Scans a `where=` clause character-by-character, tracking single- and double-quoted
@@ -363,6 +364,66 @@ function validateWhereClause(where: string | undefined): string | undefined {
363
364
  return trimmed;
364
365
  }
365
366
 
367
+ /**
368
+ * Keep raw reads deliberately narrower than SQLite's full grammar. Comments are
369
+ * rejected, and a statement terminator is accepted only as the final
370
+ * non-whitespace character. Quoted semicolons remain ordinary query data.
371
+ */
372
+ export function validateSqliteReadQuery(sql: string): string {
373
+ const trimmed = sql.trim();
374
+ if (!/^SELECT\b/i.test(trimmed)) {
375
+ throw new ToolError(RAW_QUERY_ERROR);
376
+ }
377
+
378
+ let quoteEnd: "'" | '"' | "`" | "]" | null = null;
379
+ for (let index = 0; index < trimmed.length; index++) {
380
+ const char = trimmed[index];
381
+ const next = trimmed[index + 1];
382
+ if (char === "\0") {
383
+ throw new ToolError(RAW_QUERY_ERROR);
384
+ }
385
+
386
+ if (quoteEnd) {
387
+ if (char === quoteEnd) {
388
+ if (quoteEnd !== "]" && next === quoteEnd) {
389
+ index += 1;
390
+ } else {
391
+ quoteEnd = null;
392
+ }
393
+ }
394
+ continue;
395
+ }
396
+
397
+ if (char === "'" || char === '"' || char === "`") {
398
+ quoteEnd = char;
399
+ continue;
400
+ }
401
+ if (char === "[") {
402
+ quoteEnd = "]";
403
+ continue;
404
+ }
405
+ if ((char === "-" && next === "-") || (char === "/" && next === "*") || (char === "*" && next === "/")) {
406
+ throw new ToolError(RAW_QUERY_ERROR);
407
+ }
408
+ if (char === ";" && trimmed.slice(index + 1).trim().length > 0) {
409
+ throw new ToolError(RAW_QUERY_ERROR);
410
+ }
411
+ }
412
+
413
+ if (quoteEnd) {
414
+ throw new ToolError(RAW_QUERY_ERROR);
415
+ }
416
+ return trimmed;
417
+ }
418
+
419
+ export function enforceSqliteQueryOnly(db: Database): void {
420
+ db.run("PRAGMA query_only = ON");
421
+ const enabled = db.prepare<{ query_only: number }, []>("PRAGMA query_only").get()?.query_only;
422
+ if (enabled !== 1) {
423
+ throw new ToolError("SQLite read connection could not enable query-only mode");
424
+ }
425
+ }
426
+
366
427
  function normalizeWriteValue(value: unknown, column: string): SqliteBinding {
367
428
  if (value === null) return null;
368
429
  if (
@@ -444,10 +505,7 @@ export function parseSqliteSelector(subPath: string, queryString: string): Sqlit
444
505
  if (normalizedSubPath || otherKeys.length > 0) {
445
506
  throw new ToolError("SQLite raw queries cannot be combined with table selectors or pagination");
446
507
  }
447
- if (!rawQuery.trim()) {
448
- throw new ToolError("SQLite query parameter 'q' cannot be empty");
449
- }
450
- return { kind: "raw", sql: rawQuery };
508
+ return { kind: "raw", sql: validateSqliteReadQuery(rawQuery) };
451
509
  }
452
510
 
453
511
  if (!normalizedSubPath) {
@@ -602,7 +660,7 @@ export function executeReadQuery(
602
660
  sql: string,
603
661
  maxRows: number = MAX_RAW_QUERY_ROWS,
604
662
  ): { columns: string[]; rows: Record<string, unknown>[]; truncated: boolean } {
605
- const statement = db.prepare<SqliteRow, []>(sql);
663
+ const statement = db.prepare<SqliteRow, []>(validateSqliteReadQuery(sql));
606
664
  if (statement.paramsCount > 0) {
607
665
  throw new ToolError("SQLite raw queries do not support bound parameters");
608
666
  }
@@ -143,6 +143,7 @@ function tokenizePastedPathCandidates(
143
143
  text: string,
144
144
  options: DecodePastedPathOptions,
145
145
  maxCandidates: number,
146
+ requireCompletePayload = false,
146
147
  ): TokenizePastedPathsResult {
147
148
  if (text.length > MAX_PASTED_IMAGE_PASTE_CHARACTERS) return { kind: "invalid" };
148
149
  const platform = options.platform ?? process.platform;
@@ -154,7 +155,9 @@ function tokenizePastedPathCandidates(
154
155
 
155
156
  const startCandidate = (): TokenizePastedPathsResult | undefined => {
156
157
  if (candidateStarted) return undefined;
157
- if (candidates.length >= maxCandidates) return { kind: "too-many", maxCandidates, candidates: [...candidates] };
158
+ if (!requireCompletePayload && candidates.length >= maxCandidates) {
159
+ return { kind: "too-many", maxCandidates, candidates: [...candidates] };
160
+ }
158
161
  candidateStarted = true;
159
162
  return undefined;
160
163
  };
@@ -167,7 +170,9 @@ function tokenizePastedPathCandidates(
167
170
  const finishCandidate = (): TokenizePastedPathsResult | undefined => {
168
171
  if (!candidateStarted) return undefined;
169
172
  if (!candidate) return { kind: "invalid" };
170
- if (candidates.length >= maxCandidates) return { kind: "too-many", maxCandidates, candidates: [...candidates] };
173
+ if (!requireCompletePayload && candidates.length >= maxCandidates) {
174
+ return { kind: "too-many", maxCandidates, candidates: [...candidates] };
175
+ }
171
176
  const decoded = decodeListToken(candidate, options, platform);
172
177
  if (decoded === undefined) return { kind: "invalid" };
173
178
  candidates.push(decoded);
@@ -225,6 +230,7 @@ function tokenizePastedPathCandidates(
225
230
  if (state !== "normal") return { kind: "invalid" };
226
231
  const finalResult = finishCandidate();
227
232
  if (finalResult) return finalResult;
233
+ if (candidates.length > maxCandidates) return { kind: "too-many", maxCandidates, candidates };
228
234
  return candidates.length === 0 ? { kind: "invalid" } : { kind: "candidates", candidates };
229
235
  }
230
236
 
@@ -257,8 +263,12 @@ function isRecognizedClipboardTempPath(filePath: string, platform: NodeJS.Platfo
257
263
  /** Resolve one recognized clipboard-temp image path lexically, before any filesystem access. */
258
264
  export function resolvePastedImagePath(text: string, options: ResolvePastedImagePathOptions = {}): string | undefined {
259
265
  const platform = options.platform ?? process.platform;
260
- const candidate = decodePastedPathCandidate(text, options);
261
- if (!candidate || !IMAGE_FILE_EXTENSION_PATTERN.test(candidate)) return undefined;
266
+ const terminalNewlinesTrimmed = text.replace(/(?:\r\n|\r|\n)+$/, "");
267
+ if (/\r|\n/.test(terminalNewlinesTrimmed)) return undefined;
268
+ const candidates = decodePastedPathCandidates(terminalNewlinesTrimmed, options, 1);
269
+ if (candidates?.length !== 1) return undefined;
270
+ const candidate = candidates[0];
271
+ if (!IMAGE_FILE_EXTENSION_PATTERN.test(candidate)) return undefined;
262
272
  if (platform === "win32" && isRemoteWindowsPath(candidate)) return undefined;
263
273
  const resolved = resolveCandidate(candidate, options, platform);
264
274
  return isRecognizedClipboardTempPath(resolved, platform) ? resolved : undefined;
@@ -274,10 +284,9 @@ export function parsePastedImagePaths(
274
284
  options: ResolvePastedImagePathOptions = {},
275
285
  ): PastedImagePathParseResult | undefined {
276
286
  const platform = options.platform ?? process.platform;
277
- const tokenized = tokenizePastedPathCandidates(text, options, MAX_PASTED_IMAGE_COUNT);
287
+ const tokenized = tokenizePastedPathCandidates(text, options, MAX_PASTED_IMAGE_COUNT, true);
278
288
  if (tokenized.kind === "too-many") {
279
289
  if (
280
- tokenized.candidates.length !== MAX_PASTED_IMAGE_COUNT ||
281
290
  tokenized.candidates.some(
282
291
  candidate =>
283
292
  !IMAGE_FILE_EXTENSION_PATTERN.test(candidate) ||