@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
@@ -107,6 +107,26 @@ export class YieldQueue {
107
107
  this.#idleFlushPending = false;
108
108
  }
109
109
 
110
+ /** Drop only the queued entries of a single kind, leaving other kinds intact. */
111
+ clearKind(kind: string): void {
112
+ this.#entries.delete(kind);
113
+ }
114
+
115
+ /**
116
+ * Re-schedule an idle flush if work remains and the session is idle. Used after
117
+ * a transition (e.g. handoff) releases a delivery fence so entries queued while
118
+ * fenced are not stranded until an unrelated enqueue or agent yield.
119
+ */
120
+ rearmIdle(): void {
121
+ if (this.#options.isStreaming()) return;
122
+ for (const entries of this.#entries.values()) {
123
+ if (entries.length > 0) {
124
+ this.#scheduleIdleFlush();
125
+ return;
126
+ }
127
+ }
128
+ }
129
+
110
130
  #scheduleIdleFlush(): void {
111
131
  if (this.#idleFlushPending) return;
112
132
  this.#idleFlushPending = true;
@@ -391,7 +391,7 @@ export function formatCredentialSummary(credential: ImportableCredential): strin
391
391
  const identityPart = identity ? ` ${identity}` : "";
392
392
  let expiry = "";
393
393
  if (credential.kind === "oauth" && credential.expiresAt !== undefined) {
394
- expiry = credential.expiresAt < Date.now() ? " [expired]" : "";
394
+ expiry = credential.expiresAt <= Date.now() ? " [expired]" : "";
395
395
  }
396
396
  return `${provider} · ${kind}${identityPart} · token ${credential.redactedToken}${expiry} (from ${credential.source})`;
397
397
  }
@@ -413,7 +413,10 @@ export function formatDiscoverySummary(result: CredentialDiscoveryResult): strin
413
413
  return lines;
414
414
  }
415
415
 
416
- export function isAutoImportOAuthCredential(credential: ImportableCredential): boolean {
416
+ /** Reason shown when automatic OAuth import rejects an unusable discovery record. */
417
+ export const INVALID_AUTO_IMPORT_OAUTH_EXPIRY_REASON = "OAuth credential has no valid future expiry";
418
+
419
+ function hasAutoImportOAuthProviderOrigin(credential: ImportableCredential): boolean {
417
420
  return (
418
421
  AUTO_IMPORT_OAUTH_PROVIDER_ORIGINS[credential.provider]?.has(credential.origin) === true &&
419
422
  credential.kind === "oauth" &&
@@ -421,8 +424,30 @@ export function isAutoImportOAuthCredential(credential: ImportableCredential): b
421
424
  );
422
425
  }
423
426
 
427
+ export function isAutoImportOAuthCredential(credential: ImportableCredential, now: number = Date.now()): boolean {
428
+ return (
429
+ hasAutoImportOAuthProviderOrigin(credential) &&
430
+ typeof credential.expiresAt === "number" &&
431
+ Number.isFinite(credential.expiresAt) &&
432
+ credential.expiresAt > now
433
+ );
434
+ }
435
+
436
+ /** Keep rejected OAuth discoveries visible to operators without offering them for automatic import. */
437
+ export function getAutoImportOAuthCredentialSkips(
438
+ credentials: readonly ImportableCredential[],
439
+ now: number = Date.now(),
440
+ ): SkippedCredential[] {
441
+ return credentials.flatMap(credential =>
442
+ hasAutoImportOAuthProviderOrigin(credential) && !isAutoImportOAuthCredential(credential, now)
443
+ ? [{ origin: credential.origin, source: credential.source, reason: INVALID_AUTO_IMPORT_OAUTH_EXPIRY_REASON }]
444
+ : [],
445
+ );
446
+ }
447
+
424
448
  export function filterAutoImportOAuthCredentials(credentials: readonly ImportableCredential[]): ImportableCredential[] {
425
- return credentials.filter(isAutoImportOAuthCredential);
449
+ const now = Date.now();
450
+ return credentials.filter(credential => isAutoImportOAuthCredential(credential, now));
426
451
  }
427
452
 
428
453
  /**
@@ -49,6 +49,7 @@ interface ProviderPreset {
49
49
  baseUrl: string;
50
50
  apiKeyEnv: string;
51
51
  models: readonly string[];
52
+ modelApi?: Readonly<Record<string, ProviderSetupApi>>;
52
53
  compat?: ProviderCompatConfig;
53
54
  }
54
55
 
@@ -113,6 +114,7 @@ function resolvePresetInput(input: ProviderSetupInput): {
113
114
  apiKey?: string;
114
115
  apiKeyEnv?: string;
115
116
  models: readonly string[];
117
+ modelApi?: Readonly<Record<string, ProviderSetupApi>>;
116
118
  api: ProviderSetupApi;
117
119
  compat?: ProviderCompatConfig;
118
120
  } {
@@ -152,11 +154,32 @@ function resolvePresetInput(input: ProviderSetupInput): {
152
154
  apiKey: input.apiKey,
153
155
  apiKeyEnv: input.apiKeyEnv ?? preset?.apiKeyEnv,
154
156
  models: input.models && input.models.length > 0 ? input.models : (preset?.models ?? []),
157
+ modelApi: preset?.modelApi,
155
158
  api: preset?.api ?? apiForCompatibility(compatibility),
156
159
  compat: preset?.compat,
157
160
  };
158
161
  }
159
162
 
163
+ export function validateModelApi(
164
+ modelApi: Readonly<Record<string, string>> | undefined,
165
+ models: readonly string[],
166
+ presetId: string,
167
+ ): void {
168
+ if (!modelApi) return;
169
+ const modelSet = new Set(models);
170
+ const validApis: readonly string[] = ["openai-responses", "openai-completions", "anthropic-messages"];
171
+ for (const [key, value] of Object.entries(modelApi)) {
172
+ if (!modelSet.has(key)) {
173
+ throw new Error(`Provider preset '${presetId}' declares modelApi for unknown model '${key}'.`);
174
+ }
175
+ if (!validApis.includes(value)) {
176
+ throw new Error(
177
+ `Provider preset '${presetId}' declares invalid modelApi value '${value}' for model '${key}'.`,
178
+ );
179
+ }
180
+ }
181
+ }
182
+
160
183
  function validateSetupInput(input: ProviderSetupInput): {
161
184
  providerId: string;
162
185
  baseUrl: string;
@@ -166,6 +189,7 @@ function validateSetupInput(input: ProviderSetupInput): {
166
189
  compatibility: ProviderCompatibility;
167
190
  api: ProviderSetupApi;
168
191
  compat?: ProviderCompatConfig;
192
+ modelApi?: Readonly<Record<string, ProviderSetupApi>>;
169
193
  preset?: ProviderPreset;
170
194
  } {
171
195
  const resolved = resolvePresetInput(input);
@@ -201,6 +225,7 @@ function validateSetupInput(input: ProviderSetupInput): {
201
225
 
202
226
  const models = parseModelList(resolved.models);
203
227
  if (models.length === 0) throw new Error("At least one model id is required.");
228
+ validateModelApi(resolved.modelApi, models, resolved.preset?.id ?? resolved.providerId);
204
229
 
205
230
  return {
206
231
  providerId,
@@ -210,6 +235,7 @@ function validateSetupInput(input: ProviderSetupInput): {
210
235
  models,
211
236
  compatibility: resolved.compatibility,
212
237
  api: resolved.api,
238
+ modelApi: resolved.modelApi,
213
239
  compat: resolved.compat,
214
240
  preset: resolved.preset,
215
241
  };
@@ -275,7 +301,10 @@ export async function addApiCompatibleProvider(input: ProviderSetupInput): Promi
275
301
  baseUrl: validated.baseUrl,
276
302
  api: validated.api,
277
303
  auth: "apiKey",
278
- models: validated.models.map(id => ({ id })),
304
+ models: validated.models.map(id => {
305
+ const api = validated.modelApi?.[id];
306
+ return api ? { id, api } : { id };
307
+ }),
279
308
  };
280
309
  if (validated.compat) provider.compat = validated.compat;
281
310
  if (validated.credentialSource === "env") {
@@ -1,5 +1,6 @@
1
1
  [
2
2
  {"id":"minimax","aliases":["minimax-code"],"name":"MiniMax Coding Plan","description":"OpenAI-compatible MiniMax Coding Plan endpoint","compatibility":"openai","api":"openai-completions","providerId":"minimax-code","baseUrl":"https://api.minimax.io/v1","apiKeyEnv":"MINIMAX_CODE_API_KEY","models":["minimax-m3"],"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"reasoningContentField":"reasoning_content"}},
3
3
  {"id":"minimax-cn","aliases":["minimax-code-cn","minimaxi"],"name":"MiniMax Coding Plan (China)","description":"OpenAI-compatible MiniMax China endpoint","compatibility":"openai","api":"openai-completions","providerId":"minimax-code-cn","baseUrl":"https://api.minimaxi.com/v1","apiKeyEnv":"MINIMAX_CODE_CN_API_KEY","models":["minimax-m3"],"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"reasoningContentField":"reasoning_content"}},
4
- {"id":"glm","aliases":["zai","z-ai","bigmodel"],"name":"GLM / zAI","description":"OpenAI-compatible GLM endpoint from zAI/BigModel","compatibility":"openai","api":"openai-completions","providerId":"glm-proxy","baseUrl":"https://api.z.ai/api/paas/v4","apiKeyEnv":"ZAI_API_KEY","models":["glm-4.6"],"compat":{"supportsDeveloperRole":false,"supportsReasoningEffort":false,"thinkingFormat":"zai","reasoningContentField":"reasoning_content"}}
4
+ {"id":"glm","aliases":["zai","z-ai","bigmodel"],"name":"GLM / zAI","description":"OpenAI-compatible GLM endpoint from zAI/BigModel","compatibility":"openai","api":"openai-completions","providerId":"glm-proxy","baseUrl":"https://api.z.ai/api/paas/v4","apiKeyEnv":"ZAI_API_KEY","models":["glm-4.6"],"compat":{"supportsDeveloperRole":false,"supportsReasoningEffort":false,"thinkingFormat":"zai","reasoningContentField":"reasoning_content"}},
5
+ {"id":"alibaba-token-plan","aliases":["alibaba","token-plan"],"name":"Alibaba Token Plan","description":"OpenAI-compatible Alibaba Token Plan endpoint","compatibility":"openai","api":"openai-completions","providerId":"alibaba-token-plan","baseUrl":"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1","apiKeyEnv":"ALIBABA_TOKEN_PLAN_API_KEY","models":["qwen3.8-max-preview","glm-5.2","deepseek-v4-pro"],"modelApi":{"qwen3.8-max-preview":"openai-responses","glm-5.2":"openai-completions","deepseek-v4-pro":"openai-completions"},"compat":{"supportsDeveloperRole":false}}
5
6
  ]
@@ -1435,6 +1435,42 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1435
1435
  await runtime.ctx.handleCompactCommand(customInstructions);
1436
1436
  },
1437
1437
  },
1438
+ {
1439
+ name: "handoff",
1440
+ priority: 71,
1441
+ description: "Generate a handoff and continue in a new session",
1442
+ acpDescription: "Generate a handoff document and start a new session",
1443
+ inlineHint: "[focus instructions]",
1444
+ acpInputHint: "[focus instructions]",
1445
+ allowArgs: true,
1446
+ handle: async (command, runtime) => {
1447
+ let result: Awaited<ReturnType<typeof runtime.session.handoff>>;
1448
+ try {
1449
+ result = await runtime.session.handoff(command.args || undefined);
1450
+ } catch (err) {
1451
+ // Handoff precondition failures (nothing to hand off, streaming),
1452
+ // cancellation, and provider errors propagate as plain Errors; the
1453
+ // switch is non-destructive so the current session is unchanged.
1454
+ return usage(`Handoff failed: ${errorMessage(err)}; current session is unchanged.`, runtime);
1455
+ }
1456
+ if (!result) {
1457
+ return usage(
1458
+ "Handoff not created (cancelled or nothing to hand off); current session is unchanged.",
1459
+ runtime,
1460
+ );
1461
+ }
1462
+ await runtime.output(
1463
+ result.savedPath
1464
+ ? `Handoff created; new session started. Handoff document saved to: ${result.savedPath}`
1465
+ : "Handoff created; new session started with handoff context.",
1466
+ );
1467
+ return commandConsumed();
1468
+ },
1469
+ handleTui: async (command, runtime) => {
1470
+ runtime.ctx.editor.setText("");
1471
+ await runtime.ctx.handleHandoffCommand(command.args || undefined);
1472
+ },
1473
+ },
1438
1474
  {
1439
1475
  name: "contribute-pr",
1440
1476
  aliases: ["contribution-prep"],
@@ -1479,7 +1515,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1479
1515
  },
1480
1516
  {
1481
1517
  name: "btw",
1482
- description: "Ask an ephemeral side question using the current session context",
1518
+ description: "Start an ephemeral multi-turn side chat using the current session context",
1483
1519
  inlineHint: "<question>",
1484
1520
  allowArgs: true,
1485
1521
  handleTui: async (command, runtime) => {
package/src/ssh/utils.ts CHANGED
@@ -3,6 +3,20 @@ export function sanitizeHostName(name: string): string {
3
3
  return sanitized.length > 0 ? sanitized : "remote";
4
4
  }
5
5
 
6
+ export function validateSshDestination(username: string | undefined, host: string): string | undefined {
7
+ if (host.startsWith("-") || username?.startsWith("-")) {
8
+ return "username and host must not begin with '-'";
9
+ }
10
+ if (/\0|\r|\n/.test(host) || (username !== undefined && /\0|\r|\n/.test(username))) {
11
+ return "username and host must not contain NUL or line breaks";
12
+ }
13
+ return undefined;
14
+ }
15
+
6
16
  export function buildSshTarget(username: string | undefined, host: string): string {
17
+ const validationError = validateSshDestination(username, host);
18
+ if (validationError) {
19
+ throw new Error(`Invalid SSH destination: ${validationError}`);
20
+ }
7
21
  return username ? `${username}@${host}` : host;
8
22
  }
@@ -14,6 +14,7 @@ import * as path from "node:path";
14
14
  import { logger } from "@gajae-code/utils";
15
15
  import { isProviderEnabled } from "../capability";
16
16
  import { findAllNearestProjectConfigDirs, getConfigDirs } from "../config";
17
+ import type { Settings } from "../config/settings";
17
18
  import { listClaudePluginRoots } from "../discovery/helpers";
18
19
  import { rootContainsGjcManifest } from "../extensibility/gjc-plugins/paths";
19
20
  import { loadBundledAgents, parseAgent } from "./agents";
@@ -57,8 +58,13 @@ async function loadAgentsFromDir(dir: string, source: AgentSource): Promise<Agen
57
58
  * Precedence (highest wins): .gjc project, .gjc user, GJC plugins, then bundled
58
59
  *
59
60
  * @param cwd - Current working directory for project agent discovery
61
+ * @param activeSettings - Settings used to resolve enabled providers.
60
62
  */
61
- export async function discoverAgents(cwd: string, home: string = os.homedir()): Promise<DiscoveryResult> {
63
+ export async function discoverAgents(
64
+ cwd: string,
65
+ home: string = os.homedir(),
66
+ activeSettings?: Settings,
67
+ ): Promise<DiscoveryResult> {
62
68
  const resolvedCwd = path.resolve(cwd);
63
69
  const agentSources = Array.from(new Set(getConfigDirs("", { project: false }).map(entry => entry.source)));
64
70
 
@@ -91,7 +97,7 @@ export async function discoverAgents(cwd: string, home: string = os.homedir()):
91
97
  }
92
98
 
93
99
  // Load agents from GJC marketplace plugins.
94
- const { roots: pluginRoots } = isProviderEnabled("claude-plugins")
100
+ const { roots: pluginRoots } = isProviderEnabled("claude-plugins", activeSettings)
95
101
  ? await listClaudePluginRoots(home, resolvedCwd)
96
102
  : { roots: [] };
97
103
  const nonGjcPluginRoots = [];
@@ -7,6 +7,7 @@
7
7
  * source; registering returns a disposer.
8
8
  */
9
9
 
10
+ import { logger } from "@gajae-code/utils";
10
11
  import type { WorkflowGateEmitter } from "../modes/shared/agent-wire/workflow-gate-broker";
11
12
  import type { AskAnswerSource } from "./index";
12
13
 
@@ -31,7 +32,20 @@ export function getAskAnswerSource(sessionId: string): AskAnswerSource | undefin
31
32
  export function notifyWorkflowGateEmitterChanged(sessionId: string, emitter: WorkflowGateEmitter | undefined): void {
32
33
  if (emitter) workflowGateEmitters.set(sessionId, emitter);
33
34
  else workflowGateEmitters.delete(sessionId);
34
- for (const listener of workflowGateListeners.get(sessionId) ?? []) listener(emitter);
35
+ for (const listener of workflowGateListeners.get(sessionId) ?? []) {
36
+ // Isolate each listener: a throwing observer must never escape into an
37
+ // emitter suspend/bind/restore step, which runs inside session-transition
38
+ // transactions (e.g. handoff) whose rollback/commit correctness depends on
39
+ // this notification being no-throw.
40
+ try {
41
+ listener(emitter);
42
+ } catch (error) {
43
+ logger.warn("Workflow-gate emitter listener threw during notification", {
44
+ sessionId,
45
+ error: error instanceof Error ? error.message : String(error),
46
+ });
47
+ }
48
+ }
35
49
  }
36
50
 
37
51
  /** Observe workflow-gate emitter installation even when it occurs after session_start. */
package/src/tools/ask.ts CHANGED
@@ -317,7 +317,7 @@ function isRoundZeroRecoveryCandidate(value: unknown): boolean {
317
317
  const question = parseEncodedContainer(rawQuestion);
318
318
  if (typeof question !== "object" || question === null || !Object.hasOwn(question, "deepInterview")) return false;
319
319
  const deepInterview = parseEncodedContainer((question as Record<string, unknown>).deepInterview);
320
- if (typeof deepInterview !== "object" || deepInterview === null) return deepInterview === null;
320
+ if (typeof deepInterview !== "object" || deepInterview === null) return false;
321
321
  const metadata = deepInterview as Record<string, unknown>;
322
322
  return Object.hasOwn(metadata, "intent_contract") || Object.hasOwn(metadata, "intent_review");
323
323
  });
@@ -4,7 +4,7 @@ import type { AgentToolResult } from "@gajae-code/agent-core";
4
4
  import type { ImageContent, TextContent } from "@gajae-code/ai";
5
5
  import { htmlToMarkdown } from "@gajae-code/natives";
6
6
  import { type Component, Text } from "@gajae-code/tui";
7
- import { $which, ptree, truncate } from "@gajae-code/utils";
7
+ import { ptree, truncate } from "@gajae-code/utils";
8
8
  import type { Settings } from "../config/settings";
9
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
10
  import { type Theme, theme } from "../modes/theme/theme";
@@ -15,10 +15,8 @@ import { renderStatusLine } from "../tui";
15
15
  import { CachedOutputBlock } from "../tui/output-block";
16
16
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
17
17
  import { parseHtmlLazy } from "../utils/linkedom";
18
- import { ensureTool } from "../utils/tools-manager";
19
- import { INSANE_NOTES, tryInsaneFetch } from "../web/insane/bridge";
20
- import { validatePublicHttpUrl, validatePublicHttpUrlForInsane } from "../web/insane/url-guard";
21
- import { extractWithParallel, findParallelApiKey, getParallelExtractContent } from "../web/parallel";
18
+ import { INSANE_NOTES } from "../web/insane/bridge";
19
+ import { validatePublicHttpUrl } from "../web/insane/url-guard";
22
20
  import { specialHandlers } from "../web/scrapers";
23
21
  import type { RenderResult } from "../web/scrapers/types";
24
22
  import { finalizeOutput, loadPage, looksLikeHtml, MAX_OUTPUT_CHARS } from "../web/scrapers/types";
@@ -93,13 +91,6 @@ const MAX_INLINE_IMAGE_OUTPUT_BYTES = 300 * 1024;
93
91
  // Utilities
94
92
  // =============================================================================
95
93
 
96
- /**
97
- * Check if a command exists (cross-platform)
98
- */
99
- function hasCommand(cmd: string): boolean {
100
- return Boolean($which(cmd));
101
- }
102
-
103
94
  /**
104
95
  * Build llms.txt candidates scoped to the requested URL
105
96
  */
@@ -537,96 +528,18 @@ async function parseFeedToMarkdown(content: string, maxItems = 10): Promise<stri
537
528
  return content; // Fall back to raw content
538
529
  }
539
530
 
540
- /**
541
- * Render HTML to markdown using Parallel, jina, trafilatura, lynx (in order of preference)
542
- */
543
- async function renderHtmlToText(
544
- url: string,
531
+ export async function renderHtmlToText(
545
532
  html: string,
546
- timeout: number,
547
- settings: Settings,
548
- userSignal: AbortSignal | undefined,
549
- storage: AgentStorage | null,
533
+ signal?: AbortSignal,
550
534
  ): Promise<{ content: string; ok: boolean; method: string }> {
551
- const signal = ptree.combineSignals(userSignal, timeout * 1000);
552
- const execOptions = {
553
- mode: "group" as const,
554
- allowNonZero: true,
555
- allowAbort: true,
556
- stderr: "full" as const,
557
- signal,
558
- };
559
-
560
- // Try Parallel extract first when credentials are configured
561
- if (settings.get("providers.parallelFetch") && findParallelApiKey(storage)) {
562
- try {
563
- const parallelResult = await extractWithParallel(
564
- [url],
565
- {
566
- objective: "Extract the main content",
567
- excerpts: true,
568
- fullContent: false,
569
- signal,
570
- },
571
- storage,
572
- );
573
- const firstDocument = parallelResult.results[0];
574
- if (firstDocument) {
575
- const content = getParallelExtractContent(firstDocument);
576
- if (content.trim().length > 100 && !isLowQualityOutput(content)) {
577
- return { content, ok: true, method: "parallel" };
578
- }
579
- }
580
- } catch {
581
- // Parallel extract failed, continue to next method
582
- signal?.throwIfAborted();
583
- }
584
- }
585
-
586
- // Try jina first (reader API)
587
535
  try {
588
- const jinaUrl = `https://r.jina.ai/${url}`;
589
- const response = await fetch(jinaUrl, {
590
- headers: { Accept: "text/markdown" },
591
- signal,
592
- });
593
- if (response.ok) {
594
- const content = await response.text();
595
- if (content.trim().length > 100 && !isLowQualityOutput(content)) {
596
- return { content, ok: true, method: "jina" };
597
- }
598
- }
599
- } catch {
600
- // Jina failed, continue to next method
601
536
  signal?.throwIfAborted();
602
- }
603
-
604
- // Try trafilatura (auto-install via uv/pip)
605
- const trafilatura = await ensureTool("trafilatura", { signal, silent: true });
606
- if (trafilatura) {
607
- const result = await ptree.exec([trafilatura, "-u", url, "--output-format", "markdown"], execOptions);
608
- if (result.ok && result.stdout.trim().length > 100) {
609
- return { content: result.stdout, ok: true, method: "trafilatura" };
610
- }
611
- }
612
-
613
- // Try lynx (can't auto-install, system package)
614
- const lynx = hasCommand("lynx");
615
- if (lynx) {
616
- const result = await ptree.exec(["lynx", "-dump", "-nolist", "-width", "250", url], execOptions);
617
- if (result.ok) {
618
- return { content: result.stdout, ok: true, method: "lynx" };
619
- }
620
- }
621
-
622
- // Fall back to native converter (fastest, no network/subprocess)
623
- try {
624
537
  const content = await htmlToMarkdown(html, { cleanContent: true });
625
538
  if (content.trim().length > 100 && !isLowQualityOutput(content)) {
626
539
  return { content, ok: true, method: "native" };
627
540
  }
628
541
  } catch {
629
- // Native converter failed, continue to next method
542
+ // Native conversion failed; the caller returns its bounded raw HTML.
630
543
  signal?.throwIfAborted();
631
544
  }
632
545
  return { content: "", ok: false, method: "none" };
@@ -707,14 +620,6 @@ async function handleSpecialUrls(
707
620
  // Main Render Function
708
621
  // =============================================================================
709
622
 
710
- /**
711
- * Opt-in insane-search fallback for blocked / degraded public URL reads.
712
- *
713
- * Returns a finalized `method: "insane"` result on success, or null (so the
714
- * caller continues with its normal degraded behavior). Fail-closed: no note,
715
- * guard DNS, dependency probe, or subprocess when raw mode or the opt-in
716
- * setting is off. The public-URL guard runs BEFORE any probe/spawn.
717
- */
718
623
  export async function tryInsaneFallback(args: {
719
624
  url: string;
720
625
  finalUrl: string;
@@ -727,32 +632,7 @@ export async function tryInsaneFallback(args: {
727
632
  }): Promise<FetchRenderResult | null> {
728
633
  if (args.raw) return null;
729
634
  if (args.settings.get("web.insaneFallback") !== true) return null;
730
-
731
- const target = args.finalUrl || args.url;
732
- const guard = await validatePublicHttpUrlForInsane(target);
733
- if (!guard.ok) {
734
- args.notes.push(INSANE_NOTES.guardBlocked(guard.reason));
735
- return null;
736
- }
737
-
738
- const result = await tryInsaneFetch(guard.url.toString(), {
739
- timeoutMs: args.timeout * 1000,
740
- signal: args.signal,
741
- });
742
- if (result.ok) {
743
- const output = finalizeOutput(result.content);
744
- return {
745
- url: args.url,
746
- finalUrl: target,
747
- contentType: "text/markdown",
748
- method: "insane",
749
- content: output.content,
750
- fetchedAt: args.fetchedAt,
751
- truncated: output.truncated,
752
- notes: [...args.notes, ...result.notes],
753
- };
754
- }
755
- for (const note of result.notes) args.notes.push(note);
635
+ args.notes.push(INSANE_NOTES.securityDisabled);
756
636
  return null;
757
637
  }
758
638
 
@@ -772,6 +652,7 @@ async function renderUrl(
772
652
  if (signal?.aborted) {
773
653
  throw new ToolAbortError();
774
654
  }
655
+ const preflightSignal = ptree.combineSignals(signal, timeout * 1000);
775
656
 
776
657
  // Handle internal protocol URLs (e.g., pi-internal://) - return empty
777
658
  if (url.startsWith("pi-internal://")) {
@@ -789,7 +670,8 @@ async function renderUrl(
789
670
 
790
671
  // Step 0: Normalize URL (ensure scheme for special handlers)
791
672
  url = normalizeUrl(url);
792
- const publicUrl = await validatePublicHttpUrl(url);
673
+ const publicUrl = await validatePublicHttpUrl(url, { signal: preflightSignal });
674
+ if (signal?.aborted) throw new ToolAbortError();
793
675
  if (!publicUrl.ok) {
794
676
  notes.push(`Blocked URL fetch: target URL is not public HTTP(S): ${publicUrl.reason}`);
795
677
  return {
@@ -1138,10 +1020,10 @@ async function renderUrl(
1138
1020
  throw new ToolAbortError();
1139
1021
  }
1140
1022
 
1141
- // 5E: Render HTML with lynx or html2text
1142
- const htmlResult = await renderHtmlToText(finalUrl, rawContent, timeout, settings, signal, storage);
1023
+ // 5E: Render only the bytes already fetched above.
1024
+ const htmlResult = await renderHtmlToText(rawContent, signal);
1143
1025
  if (!htmlResult.ok) {
1144
- notes.push("html rendering failed (lynx/html2text unavailable)");
1026
+ notes.push("native HTML rendering failed");
1145
1027
  const insane = await tryInsaneFallback({ url, finalUrl, timeout, raw, settings, signal, fetchedAt, notes });
1146
1028
  if (insane) return insane;
1147
1029
  const output = finalizeOutput(rawContent);