@sayknow-cli/coding-agent 0.3.8 → 0.3.10

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 (93) hide show
  1. package/CHANGELOG.md +13 -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 +11 -3
  8. package/dist/types/config/model-resolver.d.ts +2 -2
  9. package/dist/types/config/settings-schema.d.ts +15 -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/modes/components/settings-selector.d.ts +2 -2
  13. package/dist/types/modes/components/thinking-selector.d.ts +7 -2
  14. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  15. package/dist/types/modes/interactive-mode.d.ts +2 -0
  16. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  17. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  18. package/dist/types/modes/types.d.ts +3 -0
  19. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  20. package/dist/types/notifications/config-commands.d.ts +38 -0
  21. package/dist/types/notifications/config.d.ts +16 -0
  22. package/dist/types/notifications/index.d.ts +14 -1
  23. package/dist/types/notifications/lifecycle-commands.d.ts +1 -0
  24. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  25. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  26. package/dist/types/notifications/rich-draft.d.ts +68 -0
  27. package/dist/types/notifications/rich-render.d.ts +90 -0
  28. package/dist/types/notifications/telegram-daemon.d.ts +22 -0
  29. package/dist/types/notifications/telegram-reference.d.ts +7 -0
  30. package/dist/types/notifications/threaded-render.d.ts +10 -0
  31. package/dist/types/notifications/topic-registry.d.ts +4 -5
  32. package/dist/types/runtime-credential-selector.d.ts +7 -0
  33. package/dist/types/sdk.d.ts +7 -1
  34. package/dist/types/session/agent-session.d.ts +6 -3
  35. package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
  36. package/dist/types/tools/index.d.ts +1 -0
  37. package/dist/types/tools/skill-discovery.d.ts +40 -0
  38. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  39. package/package.json +7 -7
  40. package/src/cli/args.ts +8 -0
  41. package/src/cli/notify-cli.ts +38 -10
  42. package/src/cli.ts +5 -0
  43. package/src/commands/launch.ts +5 -0
  44. package/src/commands/notify.ts +2 -2
  45. package/src/config/keybindings.ts +1 -1
  46. package/src/config/model-profile-activation.ts +23 -7
  47. package/src/config/model-registry.ts +107 -11
  48. package/src/config/model-resolver.ts +5 -16
  49. package/src/config/settings-schema.ts +11 -2
  50. package/src/config/settings.ts +24 -1
  51. package/src/extensibility/runtime-skill-discovery.ts +229 -0
  52. package/src/internal-urls/docs-index.generated.ts +4 -4
  53. package/src/lsp/client.ts +1 -0
  54. package/src/main.ts +26 -3
  55. package/src/modes/components/footer.ts +7 -2
  56. package/src/modes/components/settings-selector.ts +3 -3
  57. package/src/modes/components/thinking-selector.ts +83 -20
  58. package/src/modes/controllers/event-controller.ts +11 -2
  59. package/src/modes/controllers/extension-ui-controller.ts +8 -1
  60. package/src/modes/controllers/input-controller.ts +62 -26
  61. package/src/modes/controllers/selector-controller.ts +43 -0
  62. package/src/modes/interactive-mode.ts +5 -0
  63. package/src/modes/rpc/rpc-mode.ts +3 -9
  64. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  65. package/src/modes/types.ts +3 -0
  66. package/src/modes/utils/injected-user-submission.ts +94 -0
  67. package/src/modes/utils/ui-helpers.ts +48 -15
  68. package/src/notifications/config-commands.ts +90 -0
  69. package/src/notifications/config.ts +27 -0
  70. package/src/notifications/index.ts +184 -14
  71. package/src/notifications/lifecycle-commands.ts +37 -16
  72. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  73. package/src/notifications/reply-sent-store.ts +134 -0
  74. package/src/notifications/rich-draft.ts +107 -0
  75. package/src/notifications/rich-render.ts +143 -0
  76. package/src/notifications/telegram-daemon.ts +431 -91
  77. package/src/notifications/telegram-reference.ts +17 -0
  78. package/src/notifications/threaded-render.ts +42 -1
  79. package/src/notifications/topic-registry.ts +9 -8
  80. package/src/prompts/tools/search-tool-bm25.md +5 -0
  81. package/src/prompts/tools/skill-discovery.md +13 -0
  82. package/src/runtime-credential-selector.ts +35 -0
  83. package/src/sdk.ts +104 -13
  84. package/src/session/agent-session.ts +48 -22
  85. package/src/skc-runtime/psmux-detect.ts +17 -2
  86. package/src/skc-runtime/tmux-common.ts +1 -0
  87. package/src/skc-runtime/ultragoal-runtime.ts +10 -1
  88. package/src/slash-commands/builtin-registry.ts +78 -1
  89. package/src/system-prompt.ts +4 -6
  90. package/src/tools/index.ts +4 -0
  91. package/src/tools/skill-discovery.ts +73 -0
  92. package/src/tools/skill.ts +15 -2
  93. package/src/utils/pasted-image-path.ts +170 -0
@@ -143,6 +143,23 @@ export function buildActionMessage(action: {
143
143
  return { text: body, inline_keyboard };
144
144
  }
145
145
 
146
+ /** Render an `action_needed` body as raw markdown (rich-message source; the HTML fallback stays on buildActionMessage). */
147
+ export function buildActionMarkdown(action: {
148
+ kind: "ask" | "idle";
149
+ question?: string;
150
+ options?: string[];
151
+ summary?: string;
152
+ }): string {
153
+ if (action.kind === "idle") {
154
+ return action.summary ? `🟢 Agent idle\n${action.summary}` : "🟢 Agent idle";
155
+ }
156
+ const heading = `❓ **${action.question ?? "Question"}**`;
157
+ const options = action.options ?? [];
158
+ if (options.length === 0) return `${heading}\n\n(reply with text)`;
159
+ const list = options.map((label, i) => `${i + 1}. ${label.replace(/^\s*\d+[.)]\s+/, "")}`).join("\n");
160
+ return `${heading}\n\n${list}`;
161
+ }
162
+
146
163
  /** Send Telegram HTML text chunks sequentially so long messages preserve order. */
147
164
  export async function sendTelegramHtmlChunks(
148
165
  send: TelegramSend,
@@ -38,6 +38,13 @@ export interface ThreadedSend {
38
38
  * message. Set for streamed turn frames so live + finalized share one message.
39
39
  */
40
40
  editable?: boolean;
41
+ /** Rich message class metadata. Only finalized final-answer sends are ever
42
+ * rich-promoted; the daemon gate (`shouldPromoteRich`) requires exactly this. */
43
+ richClass?: "final";
44
+ /** Rich final-answer markdown (raw). Delivery marker derived ONLY from a frame's `finalAnswer` bit; never inferred from `phase`. */
45
+ richMarkdown?: string;
46
+ /** Live-turn raw markdown for opt-in draft streaming (set ONLY on non-finalized turn frames; never triggers rich-final promotion, which requires `lane === "finalized"`). */
47
+ richDraftMarkdown?: string;
41
48
  }
42
49
 
43
50
  interface ThreadedFrame {
@@ -58,6 +65,7 @@ interface ThreadedFrame {
58
65
  cwd?: unknown;
59
66
  // turn_stream
60
67
  phase?: unknown;
68
+ finalAnswer?: boolean;
61
69
  text?: unknown;
62
70
  messageRef?: unknown;
63
71
  // image_attachment / file_attachment
@@ -69,12 +77,19 @@ interface ThreadedFrame {
69
77
  // config_update
70
78
  verbosity?: unknown;
71
79
  redact?: unknown;
80
+ // control_command_result
81
+ status?: unknown;
82
+ message?: unknown;
72
83
  }
73
84
 
74
85
  function str(v: unknown): string | undefined {
75
86
  return typeof v === "string" && v.length > 0 ? v : undefined;
76
87
  }
77
88
 
89
+ function isDotOnlyText(value: string): boolean {
90
+ return /^[.\s]+$/.test(value);
91
+ }
92
+
78
93
  /** Format the one-time identity header as pinned bullets. */
79
94
  export function formatIdentityHeader(frame: {
80
95
  repo?: unknown;
@@ -139,6 +154,7 @@ export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefi
139
154
  case "turn_stream": {
140
155
  const raw = str(frame.text);
141
156
  if (!raw) return undefined;
157
+ if (frame.phase === "finalized" && isDotOnlyText(raw)) return undefined;
142
158
  const text = markdownToTelegramHtml(raw);
143
159
  const finalized = frame.phase === "finalized";
144
160
  // A per-turn ref ties the streamed live edits and the finalized text to
@@ -156,6 +172,16 @@ export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefi
156
172
  text,
157
173
  coalesceKey,
158
174
  editable: coalesceKey !== undefined,
175
+ // Rich-final markers are set ONLY on a non-editable finalized final
176
+ // (no messageRef / coalesceKey). A streamed (editable) final owns a live
177
+ // message edited in place, so it is never rich-promoted and carries no
178
+ // rich marker to leak into split continuations.
179
+ richClass: frame.finalAnswer === true && coalesceKey === undefined ? "final" : undefined,
180
+ richMarkdown: frame.finalAnswer === true && coalesceKey === undefined ? raw : undefined,
181
+ // Live-only draft marker: carries the RAW markdown on non-finalized turn
182
+ // frames so the opt-in draft gate has the source. It never arms rich-final
183
+ // promotion (shouldPromoteRich requires lane === "finalized").
184
+ richDraftMarkdown: finalized ? undefined : raw,
159
185
  };
160
186
  }
161
187
  case "image_attachment": {
@@ -188,9 +214,24 @@ export function renderThreadedFrame(frame: ThreadedFrame): ThreadedSend | undefi
188
214
  const redact = typeof frame.redact === "boolean" ? `redact ${frame.redact ? "on" : "off"}` : undefined;
189
215
  const parts = [verbosity ? `verbosity ${verbosity}` : undefined, redact].filter(Boolean);
190
216
  return parts.length
191
- ? { method: "sendMessage", lane: "idle", text: finalizeTelegramHtml(`⚙ ${escapeHtml(parts.join(", "))}`) }
217
+ ? {
218
+ method: "sendMessage",
219
+ lane: "idle",
220
+ text: finalizeTelegramHtml(`⚙ ${escapeHtml(parts.join(", "))}`),
221
+ }
192
222
  : undefined;
193
223
  }
224
+ case "control_command_result": {
225
+ const message = str(frame.message);
226
+ if (!message) return undefined;
227
+ const status = str(frame.status);
228
+ const prefix = status === "ok" ? "✅" : status === "unavailable" ? "⚠️" : "❌";
229
+ return {
230
+ method: "sendMessage",
231
+ lane: "idle",
232
+ text: finalizeTelegramHtml(`${prefix} ${escapeHtml(truncate(message, 1200))}`),
233
+ };
234
+ }
194
235
  default:
195
236
  return undefined;
196
237
  }
@@ -120,15 +120,16 @@ export class TopicRegistry {
120
120
  return record ? !record.identitySent : true;
121
121
  }
122
122
 
123
- /**
124
- * Record the topic's applied title. Returns `true` when it changed (so the
125
- * caller should `editForumTopic`), `false` when already current or unknown.
126
- */
127
- applyName(sessionId: string, name: string): boolean {
123
+ /** Whether a known session topic's applied title differs from `name`. */
124
+ needsRename(sessionId: string, name: string): boolean {
128
125
  const record = this.topics.get(sessionId);
129
- if (!record || record.name === name) return false;
130
- record.name = name;
131
- return true;
126
+ return record !== undefined && record.name !== name;
127
+ }
128
+
129
+ /** Commit a successfully-applied Telegram topic title. */
130
+ markNameApplied(sessionId: string, name: string): void {
131
+ const record = this.topics.get(sessionId);
132
+ if (record) record.name = name;
132
133
  }
133
134
 
134
135
  /** Remove a session topic record after Telegram deletes the topic. */
@@ -21,3 +21,8 @@ Returns JSON with:
21
21
  - `activated_tools` — tools activated by this search call
22
22
  - `match_count` — number of ranked matches returned by the search
23
23
  - `total_tools`
24
+
25
+ Match details include:
26
+ - `server_name` — MCP server name when the activated result is an MCP tool
27
+ - `mcp_tool_name` — original MCP tool name when applicable
28
+ - `schema_keys` — searchable input property names
@@ -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 `.skc/skills` and user `~/.skc/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 "@sayknow-cli/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
@@ -10,6 +10,7 @@ import {
10
10
  } from "@sayknow-cli/agent-core";
11
11
  import {
12
12
  type Api,
13
+ type AuthCredentialSelector,
13
14
  type CredentialDisabledEvent,
14
15
  type Message,
15
16
  type Model,
@@ -40,7 +41,7 @@ import {
40
41
  import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled, jobElapsedMs } from "./async";
41
42
  import { loadCapability } from "./capability";
42
43
  import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
43
- import { ModelRegistry } from "./config/model-registry";
44
+ import { kNoAuth, ModelRegistry } from "./config/model-registry";
44
45
  import {
45
46
  defaultModelPerProvider,
46
47
  formatModelString,
@@ -123,7 +124,12 @@ import {
123
124
  } from "./system-prompt";
124
125
  import { AgentOutputManager } from "./task/output-manager";
125
126
  import { parseThinkingLevel, resolveThinkingLevelForModel, toReasoningEffort } from "./thinking";
126
- import { collectDiscoverableTools, type DiscoverableTool } from "./tool-discovery/tool-index";
127
+ import {
128
+ collectDiscoverableTools,
129
+ type DiscoverableTool,
130
+ isMCPBridgeTool,
131
+ isMCPToolName,
132
+ } from "./tool-discovery/tool-index";
127
133
  import {
128
134
  applyConfiguredSearchTimeout,
129
135
  BashTool,
@@ -269,6 +275,8 @@ export interface CreateAgentSessionOptions {
269
275
  /** Optional provider-facing session identifier for prompt caches and sticky auth selection.
270
276
  * Keeps persisted session files isolated while reusing provider-side caches. */
271
277
  providerSessionId?: string;
278
+ /** Runtime credential selector for multi-account auth pools. */
279
+ credentialSelector?: { provider?: string; selector: AuthCredentialSelector; raw: string };
272
280
 
273
281
  /** Custom tools to register (in addition to built-in tools). Accepts both CustomTool and ToolDefinition. */
274
282
  customTools?: (CustomTool | ToolDefinition)[];
@@ -900,10 +908,22 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
900
908
  startupCredentialDisabledEvents.push(event);
901
909
  }
902
910
  });
911
+ let runtimeCredentialSelectorInstalled = false;
912
+ const installRuntimeCredentialSelector = (provider: string): void => {
913
+ if (!options.credentialSelector || runtimeCredentialSelectorInstalled) return;
914
+ authStorage.setRuntimeCredentialSelector(provider, options.credentialSelector.selector);
915
+ runtimeCredentialSelectorInstalled = true;
916
+ };
917
+ const earlyCredentialSelectorProvider = options.credentialSelector?.provider ?? options.model?.provider;
918
+ if (earlyCredentialSelectorProvider) {
919
+ installRuntimeCredentialSelector(earlyCredentialSelectorProvider);
920
+ }
903
921
  const settings = options.settings ?? (await logger.time("settings", Settings.init, { cwd, agentDir }));
904
922
  modelRegistry.applyConfiguredModelBindings(settings);
905
923
  logger.time("initializeWithSettings", initializeWithSettings, settings);
906
- if (!options.modelRegistry) {
924
+ const canRefreshModelsBeforeCredentialSelector =
925
+ !options.credentialSelector || runtimeCredentialSelectorInstalled || options.modelRegistry !== undefined;
926
+ if (!options.modelRegistry && canRefreshModelsBeforeCredentialSelector) {
907
927
  modelRegistry.refreshInBackground();
908
928
  }
909
929
  // Kick off workspace tree discovery early. The native workspace scan returns
@@ -969,7 +989,29 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
969
989
  return cached;
970
990
  }
971
991
 
972
- const hasKey = !!(await modelRegistry.getApiKey(candidate, providerSessionId));
992
+ const credentialSelector =
993
+ options.credentialSelector && !runtimeCredentialSelectorInstalled
994
+ ? options.credentialSelector.provider === undefined ||
995
+ options.credentialSelector.provider === candidate.provider
996
+ ? options.credentialSelector.selector
997
+ : undefined
998
+ : undefined;
999
+ if (options.credentialSelector?.provider && options.credentialSelector.provider !== candidate.provider) {
1000
+ modelApiKeyAvailability.set(availabilityKey, false);
1001
+ return false;
1002
+ }
1003
+ const key = await modelRegistry.getApiKey(candidate, providerSessionId, { credentialSelector }).catch(error => {
1004
+ if (credentialSelector) {
1005
+ logger.debug("Credential selector did not match model availability candidate", {
1006
+ provider: candidate.provider,
1007
+ model: candidate.id,
1008
+ error: error instanceof Error ? error.message : String(error),
1009
+ });
1010
+ return undefined;
1011
+ }
1012
+ throw error;
1013
+ });
1014
+ const hasKey = Boolean(key) && (!credentialSelector || key !== kNoAuth);
973
1015
  modelApiKeyAvailability.set(availabilityKey, hasKey);
974
1016
  return hasKey;
975
1017
  };
@@ -1044,6 +1086,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1044
1086
  const taskDepth = options.taskDepth ?? 0;
1045
1087
 
1046
1088
  let thinkingLevel = options.thinkingLevel;
1089
+ const hasExplicitDefaultThinkingLevel = settings.has("defaultThinkingLevel");
1090
+ let thinkingLevelFromSchemaDefault = false;
1047
1091
 
1048
1092
  // If session has data and includes a thinking entry, restore it
1049
1093
  if (thinkingLevel === undefined && hasExistingSession && hasThinkingEntry) {
@@ -1054,13 +1098,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1054
1098
  thinkingLevel = defaultRoleSpec.thinkingLevel;
1055
1099
  }
1056
1100
 
1057
- // Prefer the selected model's configured defaultLevel, otherwise fall back
1058
- // to the global settings default.
1101
+ // An explicit user/project default should win over the model's bundled
1102
+ // defaultLevel. The schema default is only a final fallback so model metadata
1103
+ // can keep driving first-run behavior until the user chooses "Set as default".
1104
+ if (thinkingLevel === undefined && hasExplicitDefaultThinkingLevel) {
1105
+ thinkingLevel = settings.get("defaultThinkingLevel");
1106
+ }
1107
+
1059
1108
  if (thinkingLevel === undefined && model?.thinking?.defaultLevel !== undefined) {
1060
1109
  thinkingLevel = model.thinking.defaultLevel;
1061
1110
  }
1111
+
1062
1112
  if (thinkingLevel === undefined) {
1063
1113
  thinkingLevel = settings.get("defaultThinkingLevel");
1114
+ thinkingLevelFromSchemaDefault = true;
1064
1115
  }
1065
1116
  if (model) {
1066
1117
  const resolvedModel = model;
@@ -1605,6 +1656,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1605
1656
  if (resolved) {
1606
1657
  model = resolved;
1607
1658
  modelFallbackMessage = undefined;
1659
+ if (thinkingLevelFromSchemaDefault && resolved.thinking?.defaultLevel !== undefined) {
1660
+ thinkingLevel = resolved.thinking.defaultLevel;
1661
+ thinkingLevelFromSchemaDefault = false;
1662
+ }
1663
+ thinkingLevel = resolveThinkingLevelForModel(resolved, thinkingLevel);
1608
1664
  } else {
1609
1665
  modelFallbackMessage = `Model "${options.modelPattern}" not found`;
1610
1666
  }
@@ -1657,6 +1713,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1657
1713
  }
1658
1714
  }
1659
1715
 
1716
+ if (options.credentialSelector && !runtimeCredentialSelectorInstalled) {
1717
+ const credentialProvider = options.credentialSelector.provider ?? model?.provider;
1718
+ if (!credentialProvider) {
1719
+ throw new Error(
1720
+ `--credential ${options.credentialSelector.raw} requires a resolved model or an explicit provider prefix`,
1721
+ );
1722
+ }
1723
+ installRuntimeCredentialSelector(credentialProvider);
1724
+ if (!options.modelRegistry && !canRefreshModelsBeforeCredentialSelector) {
1725
+ modelRegistry.refreshInBackground();
1726
+ }
1727
+ }
1660
1728
  const customCommandsResult: CustomCommandsLoadResult = { commands: [], errors: [] };
1661
1729
 
1662
1730
  let extensionRunner: ExtensionRunner | undefined;
@@ -1812,7 +1880,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1812
1880
  { source: "builtin" },
1813
1881
  )
1814
1882
  : [];
1815
- const discoverableToolsForDesc: DiscoverableTool[] = [...discoverableBuiltinTools];
1883
+ const discoverableMCPTools: DiscoverableTool[] = mcpDiscoveryEnabled
1884
+ ? collectDiscoverableTools(
1885
+ Array.from(tools.values()).filter(
1886
+ tool => isMCPToolName(tool.name) && !activeToolNames.has(tool.name),
1887
+ ),
1888
+ { source: "mcp" },
1889
+ )
1890
+ : [];
1891
+ const discoverableToolsForDesc: DiscoverableTool[] = [...discoverableBuiltinTools, ...discoverableMCPTools];
1816
1892
  const promptTools = buildSystemPromptToolMetadata(tools, {
1817
1893
  search_tool_bm25: { description: renderSearchToolBm25Description(discoverableToolsForDesc) },
1818
1894
  });
@@ -1861,8 +1937,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1861
1937
  intentField,
1862
1938
  mcpDiscoveryMode: false,
1863
1939
  mcpDiscoveryServerSummaries: [],
1864
- toolDiscoveryActive: effectiveDiscoveryMode === "all",
1865
- discoverableTools: discoverableBuiltinTools
1940
+ toolDiscoveryActive: effectiveDiscoveryMode === "all" || mcpDiscoveryEnabled,
1941
+ discoverableTools: [...discoverableBuiltinTools, ...discoverableMCPTools]
1866
1942
  .map(tool => ({ name: tool.name, summary: tool.summary }))
1867
1943
  .sort((a, b) => a.name.localeCompare(b.name)),
1868
1944
  eagerTasks,
@@ -1893,11 +1969,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1893
1969
  : toolNamesFromRegistry;
1894
1970
  const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
1895
1971
  const requestedToolNameSet = new Set(normalizedRequested);
1896
- // Effective discovery mode only covers built-in tools; MCP tool discovery
1897
- // is quarantined from the SKC public surface.
1972
+ // Generic tool discovery hides non-essential built-ins behind search_tool_bm25.
1973
+ // Legacy MCP discovery remains separately controlled by mcp.discoveryMode so
1974
+ // loaded MCP catalogs can be searched without enabling every hidden built-in.
1898
1975
  const toolsDiscoveryModeSetting = settings.get("tools.discoveryMode");
1899
1976
  const effectiveDiscoveryMode: "off" | "all" = toolsDiscoveryModeSetting === "all" ? "all" : "off";
1900
- const mcpDiscoveryEnabled = false;
1977
+ const mcpDiscoveryEnabled = settings.get("mcp.discoveryMode") === true;
1901
1978
  const defaultInactiveToolNames = new Set(
1902
1979
  registeredTools.filter(tool => tool.definition.defaultInactive).map(tool => tool.definition.name),
1903
1980
  );
@@ -1910,6 +1987,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1910
1987
  const discoveryDefaultServerToolNames: string[] = [];
1911
1988
  let initialSelectedMCPToolNames: string[] = [];
1912
1989
  let defaultSelectedMCPToolNames: string[] = [];
1990
+ if (mcpDiscoveryEnabled) {
1991
+ const defaultServerNames = new Set(settings.get("mcp.discoveryDefaultServers") ?? []);
1992
+ for (const tool of toolRegistry.values()) {
1993
+ if (!isMCPBridgeTool(tool)) continue;
1994
+ discoverableMCPToolNames.add(tool.name);
1995
+ if (initialRequestedActiveToolNames.includes(tool.name)) {
1996
+ explicitlyRequestedMCPToolNames.push(tool.name);
1997
+ }
1998
+ const serverName = (tool as AgentTool & { mcpServerName?: string }).mcpServerName;
1999
+ if (serverName && defaultServerNames.has(serverName)) {
2000
+ discoveryDefaultServerToolNames.push(tool.name);
2001
+ }
2002
+ }
2003
+ }
1913
2004
  let initialToolNames = [...initialRequestedActiveToolNames];
1914
2005
  if (mcpDiscoveryEnabled) {
1915
2006
  const restoredSelectedMCPToolNames = existingSession.selectedMCPToolNames.filter(name =>
@@ -2261,7 +2352,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2261
2352
  initialSelectedMCPToolNames,
2262
2353
  defaultSelectedMCPToolNames,
2263
2354
  persistInitialMCPToolSelection: !hasExistingSession,
2264
- defaultSelectedMCPServerNames: [],
2355
+ defaultSelectedMCPServerNames: settings.get("mcp.discoveryDefaultServers") ?? [],
2265
2356
  ttsrManager,
2266
2357
  obfuscator,
2267
2358
  agentId: resolvedAgentId,
@@ -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. */
@@ -1786,7 +1788,7 @@ export class AgentSession {
1786
1788
  high = mid - 1;
1787
1789
  }
1788
1790
  }
1789
- return { message: bestMessage, tokens: Math.min(bestTokens, maxTokens) };
1791
+ return { message: bestMessage, tokens: bestTokens };
1790
1792
  };
1791
1793
 
1792
1794
  for (let i = providerMessages.length - 1; i >= 0; i--) {
@@ -1806,9 +1808,11 @@ export class AgentSession {
1806
1808
  recordSkip("token-limit");
1807
1809
  if (selected.length === 0) {
1808
1810
  const truncated = truncateMessageToTokenBudget(sanitized);
1809
- selected.unshift(truncated.message);
1810
- approximateTokens = truncated.tokens;
1811
- skippedReasons["newest-message-truncated"] = (skippedReasons["newest-message-truncated"] ?? 0) + 1;
1811
+ if (truncated.tokens <= maxTokens) {
1812
+ selected.unshift(truncated.message);
1813
+ approximateTokens = truncated.tokens;
1814
+ skippedReasons["newest-message-truncated"] = (skippedReasons["newest-message-truncated"] ?? 0) + 1;
1815
+ }
1812
1816
  }
1813
1817
  break;
1814
1818
  }
@@ -5302,7 +5306,9 @@ export class AgentSession {
5302
5306
  throw new AgentBusyError();
5303
5307
  }
5304
5308
  if (options.streamingBehavior === "followUp") {
5305
- await this.#queueFollowUp(expandedText, options?.images);
5309
+ await this.#queueFollowUp(expandedText, options?.images, {
5310
+ forceOneAtATime: options.followUpQueuePolicy === "sequential",
5311
+ });
5306
5312
  } else {
5307
5313
  await this.#queueSteer(expandedText, options?.images);
5308
5314
  }
@@ -5420,7 +5426,7 @@ export class AgentSession {
5420
5426
 
5421
5427
  async promptCustomMessage<T = unknown>(
5422
5428
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
5423
- options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice">,
5429
+ options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice" | "followUpQueuePolicy">,
5424
5430
  ): Promise<void> {
5425
5431
  const textContent =
5426
5432
  typeof message.content === "string"
@@ -5434,7 +5440,10 @@ export class AgentSession {
5434
5440
  if (!options?.streamingBehavior) {
5435
5441
  throw new AgentBusyError();
5436
5442
  }
5437
- await this.sendCustomMessage(message, { deliverAs: options.streamingBehavior });
5443
+ await this.sendCustomMessage(message, {
5444
+ deliverAs: options.streamingBehavior,
5445
+ followUpQueuePolicy: options.followUpQueuePolicy,
5446
+ });
5438
5447
  return;
5439
5448
  }
5440
5449
 
@@ -5781,13 +5790,19 @@ export class AgentSession {
5781
5790
  /**
5782
5791
  * Queue a follow-up message to process after the agent would otherwise stop.
5783
5792
  */
5784
- 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> {
5785
5798
  if (text.startsWith("/")) {
5786
5799
  this.#throwIfExtensionCommand(text);
5787
5800
  }
5788
5801
 
5789
5802
  const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]);
5790
- await this.#queueFollowUp(expandedText, images);
5803
+ await this.#queueFollowUp(expandedText, images, {
5804
+ forceOneAtATime: options?.followUpQueuePolicy === "sequential",
5805
+ });
5791
5806
  }
5792
5807
 
5793
5808
  /**
@@ -5824,19 +5839,22 @@ export class AgentSession {
5824
5839
  /**
5825
5840
  * Internal: Queue a follow-up message (already expanded, no extension command check).
5826
5841
  */
5827
- async #queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
5842
+ async #queueFollowUp(text: string, images?: ImageContent[], options?: { forceOneAtATime?: boolean }): Promise<void> {
5828
5843
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
5829
5844
  this.#followUpMessages.push(this.#createQueuedDisplayEntry(displayText));
5830
5845
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
5831
5846
  if (images && images.length > 0) {
5832
5847
  content.push(...images);
5833
5848
  }
5834
- this.agent.followUp({
5835
- role: "user",
5836
- content,
5837
- attribution: "user",
5838
- timestamp: Date.now(),
5839
- });
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
+ );
5840
5858
  // When fully idle AND the session is in a resumable assistant-ended state,
5841
5859
  // schedule an immediate continue so the queued follow-up is delivered
5842
5860
  // without waiting for the next user turn. We gate on isStreaming (model
@@ -5984,7 +6002,11 @@ export class AgentSession {
5984
6002
  */
5985
6003
  async sendCustomMessage<T = unknown>(
5986
6004
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
5987
- options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
6005
+ options?: {
6006
+ triggerTurn?: boolean;
6007
+ deliverAs?: "steer" | "followUp" | "nextTurn";
6008
+ followUpQueuePolicy?: "respect-mode" | "sequential";
6009
+ },
5988
6010
  ): Promise<void> {
5989
6011
  const appMessage: CustomMessage<T> = {
5990
6012
  role: "custom",
@@ -6002,7 +6024,10 @@ export class AgentSession {
6002
6024
  }
6003
6025
 
6004
6026
  if (options?.deliverAs === "followUp") {
6005
- this.agent.followUp(appMessage);
6027
+ this.agent.followUp(
6028
+ appMessage,
6029
+ options.followUpQueuePolicy === "sequential" ? { forceOneAtATime: true } : undefined,
6030
+ );
6006
6031
  } else {
6007
6032
  this.agent.steer(appMessage);
6008
6033
  }
@@ -6928,7 +6953,7 @@ export class AgentSession {
6928
6953
 
6929
6954
  /**
6930
6955
  * Set thinking level.
6931
- * 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.
6932
6957
  */
6933
6958
  setThinkingLevel(level: ThinkingLevel | undefined, persist: boolean = false): void {
6934
6959
  const effectiveLevel = resolveThinkingLevelForModel(this.model, level);
@@ -6937,11 +6962,12 @@ export class AgentSession {
6937
6962
  this.#thinkingLevel = effectiveLevel;
6938
6963
  this.agent.setThinkingLevel(toReasoningEffort(effectiveLevel));
6939
6964
 
6965
+ if (persist && effectiveLevel !== undefined) {
6966
+ this.settings.set("defaultThinkingLevel", effectiveLevel);
6967
+ }
6968
+
6940
6969
  if (isChanging) {
6941
6970
  this.sessionManager.appendThinkingLevelChange(effectiveLevel);
6942
- if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) {
6943
- this.settings.set("defaultThinkingLevel", effectiveLevel);
6944
- }
6945
6971
  this.#emit({ type: "thinking_level_changed", thinkingLevel: effectiveLevel });
6946
6972
  }
6947
6973
  }
@@ -115,6 +115,20 @@ function outputMentionsPsmux(output: string): boolean {
115
115
  return PSMUX_VERSION_MARKERS.some(marker => output.includes(marker));
116
116
  }
117
117
 
118
+ function normalizedCommandBaseName(command: string): string {
119
+ const normalized = command
120
+ .trim()
121
+ .replace(/^['"]|['"]$/g, "")
122
+ .replace(/\\/g, "/");
123
+ const basename = normalized.slice(normalized.lastIndexOf("/") + 1).toLowerCase();
124
+ return basename.endsWith(".exe") ? basename.slice(0, -4) : basename;
125
+ }
126
+
127
+ function isNamedPsmuxCommand(command: string): boolean {
128
+ const basename = normalizedCommandBaseName(command);
129
+ return basename === "psmux" || basename === "pmux";
130
+ }
131
+
118
132
  function resolveBinaryPath(candidate: string): string | null {
119
133
  return activeBinaryResolver(candidate);
120
134
  }
@@ -194,13 +208,14 @@ export function resolveSkcTmuxBinary(options: ResolveSkcTmuxBinaryOptions = {}):
194
208
  const runner = options.runner ?? readSpawnRunner();
195
209
  const explicit = env.SKC_TMUX_COMMAND?.trim() || env.SKC_TEAM_TMUX_COMMAND?.trim();
196
210
  if (explicit) {
197
- const isPsmux = detectPsmux(explicit, { env, runner });
211
+ const isPsmux =
212
+ platform === "win32" && isNamedPsmuxCommand(explicit) ? true : detectPsmux(explicit, { env, runner });
198
213
  return { command: explicit, isPsmux, viaExplicitOverride: true };
199
214
  }
200
215
  if (platform === "win32") {
201
216
  for (const candidate of PSMUX_BINARY_NAMES) {
202
217
  if (resolveBinaryPath(candidate)) {
203
- const isPsmux = detectPsmux(candidate, { env, runner });
218
+ const isPsmux = isNamedPsmuxCommand(candidate) ? true : detectPsmux(candidate, { env, runner });
204
219
  return { command: candidate, isPsmux, viaExplicitOverride: false };
205
220
  }
206
221
  }
@@ -4,6 +4,7 @@ import { resolveSkcTmuxBinary } from "./psmux-detect";
4
4
  export const SKC_DEFAULT_TMUX_SESSION = "sayknow_cli";
5
5
  export const SKC_TMUX_SESSION_PREFIX = `${SKC_DEFAULT_TMUX_SESSION}_`;
6
6
  export const SKC_TMUX_COMMAND_ENV = "SKC_TMUX_COMMAND";
7
+ export const SKC_TMUX_ACTIVE_SESSION_ENV = "SKC_TMUX_ACTIVE_SESSION";
7
8
  export const SKC_TMUX_PROFILE_ENV = "SKC_TMUX_PROFILE";
8
9
  export const SKC_TMUX_MOUSE_ENV = "SKC_MOUSE";
9
10
  export const SKC_TMUX_PROFILE_OPTION = "@skc-profile";
@@ -4544,6 +4544,15 @@ function parseGitNameStatus(output: string): UltragoalChangeSetPath[] {
4544
4544
  return rows;
4545
4545
  }
4546
4546
 
4547
+ function categorizeCiChangedPath(value: string): UltragoalChangeCategory {
4548
+ // CI_DEV_CHANGED_PATHS intentionally carries path names only. Mixed registries
4549
+ // such as settings-schema.ts require diff-level narrowing; without the diff,
4550
+ // treating the whole registry as computer-control source forces the mandatory
4551
+ // computer red-team suite on unrelated settings changes.
4552
+ if (isSettingsSchemaPath(value)) return "other";
4553
+ return categorizeComputerChangePath(value);
4554
+ }
4555
+
4547
4556
  function ciDevChangedPathRows(): UltragoalChangeSetPath[] {
4548
4557
  const raw = process.env.CI_DEV_CHANGED_PATHS;
4549
4558
  if (!raw) return [];
@@ -4554,7 +4563,7 @@ function ciDevChangedPathRows(): UltragoalChangeSetPath[] {
4554
4563
  .map(pathValue => ({
4555
4564
  path: normalizeRepoPath(pathValue),
4556
4565
  status: "unknown" as UltragoalChangeStatus,
4557
- category: categorizeComputerChangePath(pathValue),
4566
+ category: categorizeCiChangedPath(pathValue),
4558
4567
  }));
4559
4568
  }
4560
4569