@gajae-code/coding-agent 0.9.1 → 0.9.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 (62) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/types/cli/args.d.ts +1 -0
  3. package/dist/types/cli/notify-cli.d.ts +5 -0
  4. package/dist/types/cli.d.ts +3 -0
  5. package/dist/types/commands/launch.d.ts +3 -0
  6. package/dist/types/config/model-profile-activation.d.ts +7 -7
  7. package/dist/types/config/model-registry.d.ts +7 -3
  8. package/dist/types/config/model-resolver.d.ts +2 -2
  9. package/dist/types/config/settings-schema.d.ts +1 -1
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/extensibility/runtime-skill-discovery.d.ts +20 -0
  12. package/dist/types/gjc-runtime/team-runtime.d.ts +9 -0
  13. package/dist/types/modes/components/settings-selector.d.ts +2 -2
  14. package/dist/types/modes/components/thinking-selector.d.ts +6 -1
  15. package/dist/types/modes/types.d.ts +1 -0
  16. package/dist/types/notifications/config-commands.d.ts +29 -0
  17. package/dist/types/notifications/index.d.ts +12 -1
  18. package/dist/types/notifications/telegram-daemon-cli.d.ts +3 -3
  19. package/dist/types/notifications/threaded-render.d.ts +2 -0
  20. package/dist/types/notifications/topic-registry.d.ts +4 -5
  21. package/dist/types/runtime-credential-selector.d.ts +7 -0
  22. package/dist/types/sdk.d.ts +7 -1
  23. package/dist/types/session/agent-session.d.ts +6 -3
  24. package/dist/types/tools/index.d.ts +1 -0
  25. package/dist/types/tools/skill-discovery.d.ts +40 -0
  26. package/package.json +7 -7
  27. package/src/cli/args.ts +8 -0
  28. package/src/cli/notify-cli.ts +27 -1
  29. package/src/cli.ts +5 -0
  30. package/src/commands/launch.ts +5 -0
  31. package/src/commands/notify.ts +2 -2
  32. package/src/config/model-profile-activation.ts +23 -7
  33. package/src/config/model-registry.ts +21 -4
  34. package/src/config/model-resolver.ts +5 -16
  35. package/src/config/settings-schema.ts +3 -2
  36. package/src/config/settings.ts +24 -1
  37. package/src/extensibility/runtime-skill-discovery.ts +229 -0
  38. package/src/gjc-runtime/launch-tmux.ts +72 -12
  39. package/src/gjc-runtime/psmux-detect.ts +17 -2
  40. package/src/gjc-runtime/team-runtime.ts +35 -3
  41. package/src/main.ts +26 -3
  42. package/src/modes/components/settings-selector.ts +3 -3
  43. package/src/modes/components/thinking-selector.ts +79 -18
  44. package/src/modes/controllers/input-controller.ts +50 -9
  45. package/src/modes/controllers/selector-controller.ts +7 -5
  46. package/src/modes/types.ts +1 -0
  47. package/src/modes/utils/ui-helpers.ts +48 -15
  48. package/src/notifications/config-commands.ts +70 -0
  49. package/src/notifications/index.ts +156 -9
  50. package/src/notifications/rich-render.ts +1 -0
  51. package/src/notifications/telegram-daemon-cli.ts +13 -5
  52. package/src/notifications/telegram-daemon.ts +87 -4
  53. package/src/notifications/threaded-render.ts +14 -0
  54. package/src/notifications/topic-registry.ts +9 -8
  55. package/src/prompts/tools/skill-discovery.md +13 -0
  56. package/src/runtime-credential-selector.ts +35 -0
  57. package/src/sdk.ts +68 -5
  58. package/src/session/agent-session.ts +42 -18
  59. package/src/system-prompt.ts +4 -6
  60. package/src/tools/index.ts +4 -0
  61. package/src/tools/skill-discovery.ts +73 -0
  62. package/src/tools/skill.ts +15 -2
@@ -0,0 +1,13 @@
1
+ Discover project and user runtime skills without loading full skill content.
2
+
3
+ <instruction>
4
+ - Searches only custom runtime skill locations: project `.gjc/skills` and user `~/.gjc/skills`.
5
+ - Built-in, bundled, and internal workflow skills are intentionally excluded.
6
+ - Returns thin metadata only: name, description, source scope, path, and use conditions when present.
7
+ - To load a selected skill's full `SKILL.md`, invoke it through the existing `skill` tool with the exact `name` returned here.
8
+ </instruction>
9
+
10
+ Input:
11
+ - `query` (optional): words to match against skill name, description, source, or use conditions.
12
+ - `source` (optional): `all`, `project`, or `user`.
13
+ - `limit` (optional): maximum results, 1-50.
@@ -0,0 +1,35 @@
1
+ import type { AuthCredentialSelector, AuthCredentialSelectorKind } from "@gajae-code/ai";
2
+
3
+ export interface CliCredentialSelector {
4
+ provider?: string;
5
+ selector: AuthCredentialSelector;
6
+ raw: string;
7
+ }
8
+
9
+ const SELECTOR_KINDS = new Set<AuthCredentialSelectorKind>(["id", "email", "account", "project"]);
10
+
11
+ function parseSelectorBody(body: string): AuthCredentialSelector | undefined {
12
+ const separator = body.indexOf(":");
13
+ if (separator === -1) {
14
+ if (body.includes("@")) return { kind: "email", value: body };
15
+ return undefined;
16
+ }
17
+ const rawKind = body.slice(0, separator);
18
+ const value = body.slice(separator + 1).trim();
19
+ if (!SELECTOR_KINDS.has(rawKind as AuthCredentialSelectorKind) || value.length === 0) return undefined;
20
+ return { kind: rawKind as AuthCredentialSelectorKind, value };
21
+ }
22
+
23
+ export function parseCliCredentialSelector(raw: string): CliCredentialSelector {
24
+ const trimmed = raw.trim();
25
+ const slash = trimmed.indexOf("/");
26
+ const provider = slash === -1 ? undefined : trimmed.slice(0, slash).trim();
27
+ const body = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim();
28
+ const selector = parseSelectorBody(body);
29
+ if (!selector || provider === "") {
30
+ throw new Error(
31
+ `Invalid --credential selector "${raw}". Use email:name@example.com, id:123, account:<id>, project:<id>, or provider/email:name@example.com.`,
32
+ );
33
+ }
34
+ return { ...(provider ? { provider } : {}), selector, raw };
35
+ }
package/src/sdk.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  type ThinkingLevel,
10
10
  } from "@gajae-code/agent-core";
11
11
  import {
12
+ type AuthCredentialSelector,
12
13
  type CredentialDisabledEvent,
13
14
  type Message,
14
15
  type Model,
@@ -39,7 +40,7 @@ import {
39
40
  import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled, jobElapsedMs } from "./async";
40
41
  import { loadCapability } from "./capability";
41
42
  import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
42
- import { ModelRegistry } from "./config/model-registry";
43
+ import { kNoAuth, ModelRegistry } from "./config/model-registry";
43
44
  import {
44
45
  formatModelString,
45
46
  parseModelPattern,
@@ -272,6 +273,8 @@ export interface CreateAgentSessionOptions {
272
273
  /** Optional provider-facing session identifier for prompt caches and sticky auth selection.
273
274
  * Keeps persisted session files isolated while reusing provider-side caches. */
274
275
  providerSessionId?: string;
276
+ /** Runtime credential selector for multi-account auth pools. */
277
+ credentialSelector?: { provider?: string; selector: AuthCredentialSelector; raw: string };
275
278
 
276
279
  /** Custom tools to register (in addition to built-in tools). Accepts both CustomTool and ToolDefinition. */
277
280
  customTools?: (CustomTool | ToolDefinition)[];
@@ -903,10 +906,22 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
903
906
  startupCredentialDisabledEvents.push(event);
904
907
  }
905
908
  });
909
+ let runtimeCredentialSelectorInstalled = false;
910
+ const installRuntimeCredentialSelector = (provider: string): void => {
911
+ if (!options.credentialSelector || runtimeCredentialSelectorInstalled) return;
912
+ authStorage.setRuntimeCredentialSelector(provider, options.credentialSelector.selector);
913
+ runtimeCredentialSelectorInstalled = true;
914
+ };
915
+ const earlyCredentialSelectorProvider = options.credentialSelector?.provider ?? options.model?.provider;
916
+ if (earlyCredentialSelectorProvider) {
917
+ installRuntimeCredentialSelector(earlyCredentialSelectorProvider);
918
+ }
906
919
  const settings = options.settings ?? (await logger.time("settings", Settings.init, { cwd, agentDir }));
907
920
  modelRegistry.applyConfiguredModelBindings(settings);
908
921
  logger.time("initializeWithSettings", initializeWithSettings, settings);
909
- if (!options.modelRegistry) {
922
+ const canRefreshModelsBeforeCredentialSelector =
923
+ !options.credentialSelector || runtimeCredentialSelectorInstalled || options.modelRegistry !== undefined;
924
+ if (!options.modelRegistry && canRefreshModelsBeforeCredentialSelector) {
910
925
  modelRegistry.refreshInBackground();
911
926
  }
912
927
  // Kick off workspace tree discovery early. The native workspace scan returns
@@ -972,7 +987,29 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
972
987
  return cached;
973
988
  }
974
989
 
975
- const hasKey = !!(await modelRegistry.getApiKey(candidate, providerSessionId));
990
+ const credentialSelector =
991
+ options.credentialSelector && !runtimeCredentialSelectorInstalled
992
+ ? options.credentialSelector.provider === undefined ||
993
+ options.credentialSelector.provider === candidate.provider
994
+ ? options.credentialSelector.selector
995
+ : undefined
996
+ : undefined;
997
+ if (options.credentialSelector?.provider && options.credentialSelector.provider !== candidate.provider) {
998
+ modelApiKeyAvailability.set(availabilityKey, false);
999
+ return false;
1000
+ }
1001
+ const key = await modelRegistry.getApiKey(candidate, providerSessionId, { credentialSelector }).catch(error => {
1002
+ if (credentialSelector) {
1003
+ logger.debug("Credential selector did not match model availability candidate", {
1004
+ provider: candidate.provider,
1005
+ model: candidate.id,
1006
+ error: error instanceof Error ? error.message : String(error),
1007
+ });
1008
+ return undefined;
1009
+ }
1010
+ throw error;
1011
+ });
1012
+ const hasKey = Boolean(key) && (!credentialSelector || key !== kNoAuth);
976
1013
  modelApiKeyAvailability.set(availabilityKey, hasKey);
977
1014
  return hasKey;
978
1015
  };
@@ -1047,6 +1084,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1047
1084
  const taskDepth = options.taskDepth ?? 0;
1048
1085
 
1049
1086
  let thinkingLevel = options.thinkingLevel;
1087
+ const hasExplicitDefaultThinkingLevel = settings.has("defaultThinkingLevel");
1088
+ let thinkingLevelFromSchemaDefault = false;
1050
1089
 
1051
1090
  // If session has data and includes a thinking entry, restore it
1052
1091
  if (thinkingLevel === undefined && hasExistingSession && hasThinkingEntry) {
@@ -1057,13 +1096,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1057
1096
  thinkingLevel = defaultRoleSpec.thinkingLevel;
1058
1097
  }
1059
1098
 
1060
- // Prefer the selected model's configured defaultLevel, otherwise fall back
1061
- // to the global settings default.
1099
+ // An explicit user/project default should win over the model's bundled
1100
+ // defaultLevel. The schema default is only a final fallback so model metadata
1101
+ // can keep driving first-run behavior until the user chooses "Set as default".
1102
+ if (thinkingLevel === undefined && hasExplicitDefaultThinkingLevel) {
1103
+ thinkingLevel = settings.get("defaultThinkingLevel");
1104
+ }
1105
+
1062
1106
  if (thinkingLevel === undefined && model?.thinking?.defaultLevel !== undefined) {
1063
1107
  thinkingLevel = model.thinking.defaultLevel;
1064
1108
  }
1109
+
1065
1110
  if (thinkingLevel === undefined) {
1066
1111
  thinkingLevel = settings.get("defaultThinkingLevel");
1112
+ thinkingLevelFromSchemaDefault = true;
1067
1113
  }
1068
1114
  if (model) {
1069
1115
  const resolvedModel = model;
@@ -1608,6 +1654,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1608
1654
  if (resolved) {
1609
1655
  model = resolved;
1610
1656
  modelFallbackMessage = undefined;
1657
+ if (thinkingLevelFromSchemaDefault && resolved.thinking?.defaultLevel !== undefined) {
1658
+ thinkingLevel = resolved.thinking.defaultLevel;
1659
+ thinkingLevelFromSchemaDefault = false;
1660
+ }
1661
+ thinkingLevel = resolveThinkingLevelForModel(resolved, thinkingLevel);
1611
1662
  } else {
1612
1663
  modelFallbackMessage = `Model "${options.modelPattern}" not found`;
1613
1664
  }
@@ -1639,6 +1690,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1639
1690
  }
1640
1691
  }
1641
1692
 
1693
+ if (options.credentialSelector && !runtimeCredentialSelectorInstalled) {
1694
+ const credentialProvider = options.credentialSelector.provider ?? model?.provider;
1695
+ if (!credentialProvider) {
1696
+ throw new Error(
1697
+ `--credential ${options.credentialSelector.raw} requires a resolved model or an explicit provider prefix`,
1698
+ );
1699
+ }
1700
+ installRuntimeCredentialSelector(credentialProvider);
1701
+ if (!options.modelRegistry && !canRefreshModelsBeforeCredentialSelector) {
1702
+ modelRegistry.refreshInBackground();
1703
+ }
1704
+ }
1642
1705
  const customCommandsResult: CustomCommandsLoadResult = { commands: [], errors: [] };
1643
1706
 
1644
1707
  let extensionRunner: ExtensionRunner | undefined;
@@ -473,6 +473,8 @@ export interface PromptOptions {
473
473
  images?: ImageContent[];
474
474
  /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). */
475
475
  streamingBehavior?: "steer" | "followUp";
476
+ /** When set to "sequential", this follow-up is delivered one prompt at a time even if followUpMode is "all". */
477
+ followUpQueuePolicy?: "respect-mode" | "sequential";
476
478
  /** Optional tool choice override for the next LLM call. */
477
479
  toolChoice?: ToolChoice;
478
480
  /** Send as developer/system message instead of user. Providers that support it use the developer role; others fall back to user. */
@@ -5304,7 +5306,9 @@ export class AgentSession {
5304
5306
  throw new AgentBusyError();
5305
5307
  }
5306
5308
  if (options.streamingBehavior === "followUp") {
5307
- await this.#queueFollowUp(expandedText, options?.images);
5309
+ await this.#queueFollowUp(expandedText, options?.images, {
5310
+ forceOneAtATime: options.followUpQueuePolicy === "sequential",
5311
+ });
5308
5312
  } else {
5309
5313
  await this.#queueSteer(expandedText, options?.images);
5310
5314
  }
@@ -5422,7 +5426,7 @@ export class AgentSession {
5422
5426
 
5423
5427
  async promptCustomMessage<T = unknown>(
5424
5428
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
5425
- options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice">,
5429
+ options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice" | "followUpQueuePolicy">,
5426
5430
  ): Promise<void> {
5427
5431
  const textContent =
5428
5432
  typeof message.content === "string"
@@ -5436,7 +5440,10 @@ export class AgentSession {
5436
5440
  if (!options?.streamingBehavior) {
5437
5441
  throw new AgentBusyError();
5438
5442
  }
5439
- await this.sendCustomMessage(message, { deliverAs: options.streamingBehavior });
5443
+ await this.sendCustomMessage(message, {
5444
+ deliverAs: options.streamingBehavior,
5445
+ followUpQueuePolicy: options.followUpQueuePolicy,
5446
+ });
5440
5447
  return;
5441
5448
  }
5442
5449
 
@@ -5783,13 +5790,19 @@ export class AgentSession {
5783
5790
  /**
5784
5791
  * Queue a follow-up message to process after the agent would otherwise stop.
5785
5792
  */
5786
- async followUp(text: string, images?: ImageContent[]): Promise<void> {
5793
+ async followUp(
5794
+ text: string,
5795
+ images?: ImageContent[],
5796
+ options?: Pick<PromptOptions, "followUpQueuePolicy">,
5797
+ ): Promise<void> {
5787
5798
  if (text.startsWith("/")) {
5788
5799
  this.#throwIfExtensionCommand(text);
5789
5800
  }
5790
5801
 
5791
5802
  const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]);
5792
- await this.#queueFollowUp(expandedText, images);
5803
+ await this.#queueFollowUp(expandedText, images, {
5804
+ forceOneAtATime: options?.followUpQueuePolicy === "sequential",
5805
+ });
5793
5806
  }
5794
5807
 
5795
5808
  /**
@@ -5826,19 +5839,22 @@ export class AgentSession {
5826
5839
  /**
5827
5840
  * Internal: Queue a follow-up message (already expanded, no extension command check).
5828
5841
  */
5829
- async #queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
5842
+ async #queueFollowUp(text: string, images?: ImageContent[], options?: { forceOneAtATime?: boolean }): Promise<void> {
5830
5843
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
5831
5844
  this.#followUpMessages.push(this.#createQueuedDisplayEntry(displayText));
5832
5845
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
5833
5846
  if (images && images.length > 0) {
5834
5847
  content.push(...images);
5835
5848
  }
5836
- this.agent.followUp({
5837
- role: "user",
5838
- content,
5839
- attribution: "user",
5840
- timestamp: Date.now(),
5841
- });
5849
+ this.agent.followUp(
5850
+ {
5851
+ role: "user",
5852
+ content,
5853
+ attribution: "user",
5854
+ timestamp: Date.now(),
5855
+ },
5856
+ options?.forceOneAtATime ? { forceOneAtATime: true } : undefined,
5857
+ );
5842
5858
  // When fully idle AND the session is in a resumable assistant-ended state,
5843
5859
  // schedule an immediate continue so the queued follow-up is delivered
5844
5860
  // without waiting for the next user turn. We gate on isStreaming (model
@@ -5986,7 +6002,11 @@ export class AgentSession {
5986
6002
  */
5987
6003
  async sendCustomMessage<T = unknown>(
5988
6004
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
5989
- options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
6005
+ options?: {
6006
+ triggerTurn?: boolean;
6007
+ deliverAs?: "steer" | "followUp" | "nextTurn";
6008
+ followUpQueuePolicy?: "respect-mode" | "sequential";
6009
+ },
5990
6010
  ): Promise<void> {
5991
6011
  const appMessage: CustomMessage<T> = {
5992
6012
  role: "custom",
@@ -6004,7 +6024,10 @@ export class AgentSession {
6004
6024
  }
6005
6025
 
6006
6026
  if (options?.deliverAs === "followUp") {
6007
- this.agent.followUp(appMessage);
6027
+ this.agent.followUp(
6028
+ appMessage,
6029
+ options.followUpQueuePolicy === "sequential" ? { forceOneAtATime: true } : undefined,
6030
+ );
6008
6031
  } else {
6009
6032
  this.agent.steer(appMessage);
6010
6033
  }
@@ -6930,7 +6953,7 @@ export class AgentSession {
6930
6953
 
6931
6954
  /**
6932
6955
  * Set thinking level.
6933
- * Saves the effective metadata-clamped level to session and settings only if it changes.
6956
+ * Saves the effective metadata-clamped level to the session, and to settings when requested.
6934
6957
  */
6935
6958
  setThinkingLevel(level: ThinkingLevel | undefined, persist: boolean = false): void {
6936
6959
  const effectiveLevel = resolveThinkingLevelForModel(this.model, level);
@@ -6939,11 +6962,12 @@ export class AgentSession {
6939
6962
  this.#thinkingLevel = effectiveLevel;
6940
6963
  this.agent.setThinkingLevel(toReasoningEffort(effectiveLevel));
6941
6964
 
6965
+ if (persist && effectiveLevel !== undefined) {
6966
+ this.settings.set("defaultThinkingLevel", effectiveLevel);
6967
+ }
6968
+
6942
6969
  if (isChanging) {
6943
6970
  this.sessionManager.appendThinkingLevelChange(effectiveLevel);
6944
- if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) {
6945
- this.settings.set("defaultThinkingLevel", effectiveLevel);
6946
- }
6947
6971
  this.#emit({ type: "thinking_level_changed", thinkingLevel: effectiveLevel });
6948
6972
  }
6949
6973
  }
@@ -507,7 +507,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
507
507
  ? loadSkills({ ...skillsSettings, cwd: resolvedCwd }).then(result => result.skills)
508
508
  : Promise.resolve([]);
509
509
 
510
- const [resolvedCustomPrompt, resolvedAppendPrompt, systemPromptCustomization, contextFiles, skills, workspaceTree] =
510
+ const [resolvedCustomPrompt, resolvedAppendPrompt, systemPromptCustomization, contextFiles, , workspaceTree] =
511
511
  await Promise.all([
512
512
  withDeadline(
513
513
  "customPrompt",
@@ -581,11 +581,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
581
581
  description: tools?.get(name)?.description ?? "",
582
582
  }));
583
583
 
584
- // Filter skills for the rendered system prompt:
585
- // - require the `read` tool so the model can actually fetch skill content;
586
- // - drop skills with frontmatter `hide: true` (still loadable via skill:// and /skill:<name>).
587
- const hasRead = tools?.has("read");
588
- const filteredSkills = hasRead ? skills.filter(skill => skill.hide !== true) : [];
584
+ // Runtime skills are discovered by `skill_discovery` and loaded narrowly through
585
+ // `skill`; do not dump the full custom skill catalog into the core prompt.
586
+ const filteredSkills: Skill[] = [];
589
587
 
590
588
  const effectiveSystemPromptCustomization = dedupePromptSource(systemPromptCustomization, [
591
589
  resolvedCustomPrompt,
@@ -56,6 +56,7 @@ import { reportFindingTool } from "./review";
56
56
  import { SearchTool } from "./search";
57
57
  import { SearchToolBm25Tool } from "./search-tool-bm25";
58
58
  import { SkillTool } from "./skill";
59
+ import { SkillDiscoveryTool } from "./skill-discovery";
59
60
  import { loadSshTool } from "./ssh";
60
61
  import { SubagentTool } from "./subagent";
61
62
  import { TelegramSendTool } from "./telegram-send";
@@ -95,6 +96,7 @@ export * from "./review";
95
96
  export * from "./search";
96
97
  export * from "./search-tool-bm25";
97
98
  export * from "./skill";
99
+ export * from "./skill-discovery";
98
100
  export * from "./ssh";
99
101
  export * from "./subagent";
100
102
  export * from "./telegram-send";
@@ -411,6 +413,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
411
413
  todo_write: s => new TodoWriteTool(s),
412
414
  web_search: s => new WebSearchTool(s),
413
415
  search_tool_bm25: SearchToolBm25Tool.createIf,
416
+ skill_discovery: SkillDiscoveryTool.createIf,
414
417
  telegram_send: TelegramSendTool.createIf,
415
418
  write: s => new WriteTool(s),
416
419
  skill: SkillTool.createIf,
@@ -570,6 +573,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
570
573
  if (name === "search_tool_bm25") return discoveryActive;
571
574
  if (name === "calc") return session.settings.get("calc.enabled");
572
575
  if (name === "skill") return session.settings.get("skill.enabled");
576
+ if (name === "skill_discovery") return session.settings.get("skill.enabled");
573
577
  if (name === "browser") return session.settings.get("browser.enabled");
574
578
  if (name === "computer") return isComputerCallable(session);
575
579
  if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
@@ -0,0 +1,73 @@
1
+ import type { AgentTool, AgentToolResult } from "@gajae-code/agent-core";
2
+ import { prompt, untilAborted } from "@gajae-code/utils";
3
+ import * as z from "zod/v4";
4
+ import { discoverRuntimeSkills, type RuntimeSkillDiscoveryCandidate } from "../extensibility/runtime-skill-discovery";
5
+ import skillDiscoveryDescription from "../prompts/tools/skill-discovery.md" with { type: "text" };
6
+ import type { ToolSession } from ".";
7
+
8
+ const skillDiscoverySchema = z
9
+ .object({
10
+ query: z
11
+ .string()
12
+ .optional()
13
+ .describe("words to match against skill name, description, source, or use conditions"),
14
+ source: z.enum(["all", "project", "user"]).default("all").optional().describe("skill source scope to search"),
15
+ limit: z.number().min(1).max(50).default(20).optional().describe("maximum results"),
16
+ })
17
+ .strict();
18
+
19
+ export type SkillDiscoveryToolInput = z.infer<typeof skillDiscoverySchema>;
20
+
21
+ export interface SkillDiscoveryToolDetails {
22
+ candidates: RuntimeSkillDiscoveryCandidate[];
23
+ count: number;
24
+ }
25
+
26
+ export class SkillDiscoveryTool implements AgentTool<typeof skillDiscoverySchema, SkillDiscoveryToolDetails> {
27
+ readonly name = "skill_discovery";
28
+ readonly label = "SkillDiscovery";
29
+ readonly summary = "Discover project and user runtime skills by thin metadata";
30
+ readonly loadMode = "essential";
31
+ readonly description: string;
32
+ readonly parameters = skillDiscoverySchema;
33
+ readonly strict = true;
34
+
35
+ readonly #session: ToolSession;
36
+
37
+ constructor(session: ToolSession) {
38
+ this.#session = session;
39
+ this.description = prompt.render(skillDiscoveryDescription);
40
+ }
41
+
42
+ #getRuntimeSkillPolicy() {
43
+ return {
44
+ ...this.#session.settings.getGroup("skills"),
45
+ disabledExtensions: this.#session.settings.get("disabledExtensions"),
46
+ };
47
+ }
48
+
49
+ static createIf(session: ToolSession): SkillDiscoveryTool | null {
50
+ if (session.settings.get("skill.enabled") === false) return null;
51
+ return new SkillDiscoveryTool(session);
52
+ }
53
+
54
+ async execute(
55
+ _toolCallId: string,
56
+ input: SkillDiscoveryToolInput,
57
+ signal?: AbortSignal,
58
+ ): Promise<AgentToolResult<SkillDiscoveryToolDetails>> {
59
+ return untilAborted(signal, async () => {
60
+ const candidates = await discoverRuntimeSkills({
61
+ cwd: this.#session.cwd,
62
+ query: input.query,
63
+ source: input.source ?? "all",
64
+ limit: input.limit,
65
+ policy: this.#getRuntimeSkillPolicy(),
66
+ });
67
+ return {
68
+ content: [{ type: "text", text: JSON.stringify({ candidates, count: candidates.length }, null, 2) }],
69
+ details: { candidates, count: candidates.length },
70
+ };
71
+ });
72
+ }
73
+ }
@@ -19,6 +19,7 @@ import type { AgentTool, AgentToolResult } from "@gajae-code/agent-core";
19
19
  import { prompt, untilAborted } from "@gajae-code/utils";
20
20
  import * as z from "zod/v4";
21
21
  import { resolveSubskillActivationForSkillInvocation } from "../extensibility/gjc-plugins";
22
+ import { findRuntimeSkillByName } from "../extensibility/runtime-skill-discovery";
22
23
  import { buildSkillPromptMessage } from "../extensibility/skills";
23
24
  import { runNativeStateCommand } from "../gjc-runtime/state-runtime";
24
25
  import skillDescription from "../prompts/tools/skill.md" with { type: "text" };
@@ -64,6 +65,13 @@ export class SkillTool implements AgentTool<typeof skillSchema, SkillToolDetails
64
65
  this.description = prompt.render(skillDescription);
65
66
  }
66
67
 
68
+ #getRuntimeSkillPolicy() {
69
+ return {
70
+ ...this.#session.settings.getGroup("skills"),
71
+ disabledExtensions: this.#session.settings.get("disabledExtensions"),
72
+ };
73
+ }
74
+
67
75
  static createIf(session: ToolSession): SkillTool | null {
68
76
  // The tool can only chain when the session can deliver the same-turn
69
77
  // custom message. Without `sendCustomMessage` (e.g. minimal tool
@@ -110,10 +118,15 @@ export class SkillTool implements AgentTool<typeof skillSchema, SkillToolDetails
110
118
  );
111
119
  }
112
120
 
113
- const skill = skills.find(s => s.name === requestedName);
121
+ const skill =
122
+ skills.find(s => s.name === requestedName) ??
123
+ (await findRuntimeSkillByName(this.#session.cwd, requestedName, this.#getRuntimeSkillPolicy()));
114
124
  if (!skill) {
115
125
  const available = skills.map(s => s.name).sort();
116
- const hint = available.length > 0 ? ` Available: ${available.join(", ")}` : "";
126
+ const hint =
127
+ available.length > 0
128
+ ? ` Available: ${available.join(", ")}. Use skill_discovery to find project/user runtime skills.`
129
+ : " Use skill_discovery to find project/user runtime skills.";
117
130
  throw new ToolError(`skill tool: unknown skill "${requestedName}".${hint}`);
118
131
  }
119
132