@gajae-code/coding-agent 0.7.0 → 0.7.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 (101) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/types/cli/notify-cli.d.ts +2 -0
  3. package/dist/types/config/settings-schema.d.ts +39 -2
  4. package/dist/types/extensibility/shared-events.d.ts +1 -0
  5. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  6. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +1 -1
  7. package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
  8. package/dist/types/gjc-runtime/tmux-sessions.d.ts +2 -0
  9. package/dist/types/lsp/types.d.ts +2 -0
  10. package/dist/types/notifications/attachment-registry.d.ts +17 -0
  11. package/dist/types/notifications/chat-adapters.d.ts +9 -0
  12. package/dist/types/notifications/config.d.ts +9 -1
  13. package/dist/types/notifications/engine.d.ts +59 -0
  14. package/dist/types/notifications/managed-daemon.d.ts +48 -0
  15. package/dist/types/notifications/telegram-daemon.d.ts +19 -0
  16. package/dist/types/notifications/threaded-inbound.d.ts +19 -0
  17. package/dist/types/notifications/threaded-render.d.ts +6 -1
  18. package/dist/types/session/agent-session.d.ts +2 -0
  19. package/dist/types/tools/fetch.d.ts +23 -0
  20. package/dist/types/tools/index.d.ts +1 -0
  21. package/dist/types/tools/telegram-send.d.ts +32 -0
  22. package/dist/types/web/insane/bridge.d.ts +103 -0
  23. package/dist/types/web/insane/url-guard.d.ts +22 -0
  24. package/dist/types/web/search/provider.d.ts +18 -1
  25. package/dist/types/web/search/providers/insane.d.ts +53 -0
  26. package/dist/types/web/search/providers/text-citations.d.ts +23 -0
  27. package/dist/types/web/search/types.d.ts +12 -4
  28. package/package.json +10 -8
  29. package/scripts/verify-insane-vendor.ts +132 -0
  30. package/src/cli/args.ts +1 -1
  31. package/src/cli/fast-help.ts +1 -1
  32. package/src/cli/notify-cli.ts +152 -5
  33. package/src/cli.ts +1 -3
  34. package/src/commands/team.ts +1 -1
  35. package/src/config/settings-schema.ts +30 -1
  36. package/src/defaults/gjc/skills/ralplan/SKILL.md +11 -4
  37. package/src/edit/modes/replace.ts +1 -1
  38. package/src/extensibility/shared-events.ts +1 -0
  39. package/src/gjc-runtime/launch-tmux.ts +27 -5
  40. package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
  41. package/src/gjc-runtime/ralplan-runtime.ts +2 -2
  42. package/src/gjc-runtime/tmux-common.ts +8 -0
  43. package/src/gjc-runtime/tmux-sessions.ts +8 -1
  44. package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
  45. package/src/gjc-runtime/workflow-manifest.ts +7 -2
  46. package/src/hashline/hash.ts +1 -1
  47. package/src/internal-urls/docs-index.generated.ts +9 -8
  48. package/src/lsp/config.ts +16 -3
  49. package/src/lsp/defaults.json +7 -0
  50. package/src/lsp/types.ts +2 -0
  51. package/src/modes/controllers/event-controller.ts +15 -0
  52. package/src/modes/interactive-mode.ts +46 -2
  53. package/src/modes/utils/context-usage.ts +2 -2
  54. package/src/notifications/attachment-registry.ts +23 -0
  55. package/src/notifications/chat-adapters.ts +147 -0
  56. package/src/notifications/config.ts +23 -2
  57. package/src/notifications/engine.ts +100 -0
  58. package/src/notifications/index.ts +224 -45
  59. package/src/notifications/managed-daemon.ts +163 -0
  60. package/src/notifications/telegram-daemon.ts +235 -14
  61. package/src/notifications/threaded-inbound.ts +60 -4
  62. package/src/notifications/threaded-render.ts +20 -2
  63. package/src/session/agent-session.ts +82 -51
  64. package/src/tools/ask.ts +3 -2
  65. package/src/tools/fetch.ts +78 -1
  66. package/src/tools/index.ts +3 -0
  67. package/src/tools/telegram-send.ts +137 -0
  68. package/src/web/insane/bridge.ts +350 -0
  69. package/src/web/insane/url-guard.ts +155 -0
  70. package/src/web/search/provider.ts +77 -18
  71. package/src/web/search/providers/anthropic.ts +70 -3
  72. package/src/web/search/providers/codex.ts +1 -119
  73. package/src/web/search/providers/gemini.ts +99 -0
  74. package/src/web/search/providers/insane.ts +551 -0
  75. package/src/web/search/providers/openai-compatible.ts +66 -32
  76. package/src/web/search/providers/text-citations.ts +111 -0
  77. package/src/web/search/types.ts +13 -2
  78. package/vendor/insane-search/LICENSE +21 -0
  79. package/vendor/insane-search/MANIFEST.json +24 -0
  80. package/vendor/insane-search/engine/__init__.py +23 -0
  81. package/vendor/insane-search/engine/__main__.py +128 -0
  82. package/vendor/insane-search/engine/bias_check.py +183 -0
  83. package/vendor/insane-search/engine/executor.py +254 -0
  84. package/vendor/insane-search/engine/fetch_chain.py +725 -0
  85. package/vendor/insane-search/engine/learning.py +175 -0
  86. package/vendor/insane-search/engine/phase0.py +214 -0
  87. package/vendor/insane-search/engine/safety.py +91 -0
  88. package/vendor/insane-search/engine/templates/package.json +11 -0
  89. package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
  90. package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
  91. package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
  92. package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
  93. package/vendor/insane-search/engine/tests/test_u1.py +200 -0
  94. package/vendor/insane-search/engine/tests/test_u4.py +131 -0
  95. package/vendor/insane-search/engine/tests/test_u5.py +163 -0
  96. package/vendor/insane-search/engine/tests/test_u7.py +124 -0
  97. package/vendor/insane-search/engine/transport.py +211 -0
  98. package/vendor/insane-search/engine/url_transforms.py +98 -0
  99. package/vendor/insane-search/engine/validators.py +331 -0
  100. package/vendor/insane-search/engine/waf_detector.py +214 -0
  101. package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
@@ -286,6 +286,8 @@ import { ToolChoiceQueue } from "./tool-choice-queue";
286
286
  import { YieldQueue } from "./yield-queue";
287
287
 
288
288
  /** Session-specific events that extend the core AgentEvent */
289
+ export type AutoCompactionContinuationSkipReason = "auto_continue_disabled_non_resumable_tail";
290
+
289
291
  export type AgentSessionEvent =
290
292
  | AgentEvent
291
293
  | { type: "auto_compaction_start"; reason: "threshold" | "overflow" | "idle"; action: "context-full" | "handoff" }
@@ -298,6 +300,7 @@ export type AgentSessionEvent =
298
300
  errorMessage?: string;
299
301
  /** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
300
302
  skipped?: boolean;
303
+ continuationSkipReason?: AutoCompactionContinuationSkipReason;
301
304
  }
302
305
  | {
303
306
  type: "auto_retry_start";
@@ -1869,7 +1872,6 @@ export class AgentSession {
1869
1872
  this.#resolveTtsrResume();
1870
1873
  return;
1871
1874
  }
1872
- this.#ttsrAbortPending = false;
1873
1875
  this.#perToolTtsrInjections.clear();
1874
1876
  const ttsrSettings = this.#ttsrManager?.getSettings();
1875
1877
  if (ttsrSettings?.contextMode === "discard" && targetAssistantIndex !== -1) {
@@ -1898,11 +1900,22 @@ export class AgentSession {
1898
1900
  );
1899
1901
  this.#markTtsrInjected(details.rules);
1900
1902
  }
1901
- try {
1902
- await this.agent.continue();
1903
- } catch {
1904
- this.#resolveTtsrResume();
1905
- }
1903
+ await this.#scheduleAgentContinue({
1904
+ delayMs: 0,
1905
+ generation,
1906
+ shouldContinue: () => {
1907
+ this.#ttsrAbortPending = false;
1908
+ return true;
1909
+ },
1910
+ onSkip: () => {
1911
+ this.#ttsrAbortPending = false;
1912
+ this.#resolveTtsrResume();
1913
+ },
1914
+ onError: () => {
1915
+ this.#ttsrAbortPending = false;
1916
+ this.#resolveTtsrResume();
1917
+ },
1918
+ });
1906
1919
  },
1907
1920
  { delayMs: 50 },
1908
1921
  );
@@ -2214,7 +2227,7 @@ export class AgentSession {
2214
2227
  #schedulePostPromptTask(
2215
2228
  task: (signal: AbortSignal) => Promise<void>,
2216
2229
  options?: { delayMs?: number; generation?: number; onSkip?: () => void },
2217
- ): void {
2230
+ ): Promise<void> {
2218
2231
  const delayMs = options?.delayMs ?? 0;
2219
2232
  const signal = this.#postPromptTasksAbortController.signal;
2220
2233
  const scheduled = (async () => {
@@ -2236,18 +2249,20 @@ export class AgentSession {
2236
2249
  await task(signal);
2237
2250
  })();
2238
2251
  this.#trackPostPromptTask(scheduled);
2252
+ return scheduled;
2239
2253
  }
2240
2254
 
2241
2255
  #scheduleAgentContinue(options?: {
2242
2256
  delayMs?: number;
2243
2257
  generation?: number;
2258
+ skipCompactionCheck?: boolean;
2244
2259
  shouldContinue?: () => boolean;
2245
2260
  onSkip?: (reason: "generation_changed" | "aborted_signal" | "queue_drained") => void;
2246
2261
  onError?: (error: unknown) => void;
2247
- }): void {
2262
+ }): Promise<void> {
2248
2263
  const scheduledGeneration = options?.generation;
2249
2264
  const signal = this.#postPromptTasksAbortController.signal;
2250
- this.#schedulePostPromptTask(
2265
+ return this.#schedulePostPromptTask(
2251
2266
  async () => {
2252
2267
  if (signal.aborted) {
2253
2268
  options?.onSkip?.("aborted_signal");
@@ -2263,6 +2278,9 @@ export class AgentSession {
2263
2278
  }
2264
2279
  try {
2265
2280
  await this.#maybeRestoreRetryFallbackPrimary();
2281
+ if (!options?.skipCompactionCheck) {
2282
+ await this.#checkEstimatedContextBeforePrompt();
2283
+ }
2266
2284
  await this.agent.continue();
2267
2285
  } catch (error) {
2268
2286
  logger.warn("agent.continue failed after scheduling", {
@@ -2307,6 +2325,34 @@ export class AgentSession {
2307
2325
  }
2308
2326
  }
2309
2327
 
2328
+ #detectOverflowRetryContinuationSkip(): AutoCompactionContinuationSkipReason | undefined {
2329
+ this.#stripOverflowFailedTurnForRetry();
2330
+ if (this.#isResumableAgentTail()) return undefined;
2331
+ const compactionSettings = this.settings.getGroup("compaction");
2332
+ return compactionSettings.autoContinue === false ? "auto_continue_disabled_non_resumable_tail" : undefined;
2333
+ }
2334
+
2335
+ #scheduleOverflowRetryContinuation(generation: number): void {
2336
+ this.#stripOverflowFailedTurnForRetry();
2337
+ if (this.#isResumableAgentTail()) {
2338
+ this.#scheduleAgentContinue({
2339
+ delayMs: 100,
2340
+ generation,
2341
+ onSkip: reason => this.#logCompactionContinuationSkipped("overflow_retry", reason),
2342
+ onError: error => this.#logCompactionContinuationError("overflow_retry", error),
2343
+ });
2344
+ return;
2345
+ }
2346
+
2347
+ const compactionSettings = this.settings.getGroup("compaction");
2348
+ if (compactionSettings.autoContinue !== false) {
2349
+ this.#scheduleAutoContinuePrompt(generation);
2350
+ return;
2351
+ }
2352
+
2353
+ this.#logCompactionContinuationSkipped("overflow_retry", "auto_continue_disabled_non_resumable_tail");
2354
+ }
2355
+
2310
2356
  #scheduleAutoContinuePrompt(generation: number): void {
2311
2357
  const continuePrompt = async () => {
2312
2358
  await this.#promptWithMessage(
@@ -3052,6 +3098,7 @@ export class AgentSession {
3052
3098
  willRetry: event.willRetry,
3053
3099
  errorMessage: event.errorMessage,
3054
3100
  skipped: event.skipped,
3101
+ continuationSkipReason: event.continuationSkipReason,
3055
3102
  });
3056
3103
  } else if (event.type === "auto_retry_start") {
3057
3104
  await this.#extensionRunner.emit({
@@ -6722,6 +6769,8 @@ export class AgentSession {
6722
6769
  const compactionSettings = this.settings.getGroup("compaction");
6723
6770
  if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
6724
6771
  await this.#runAutoCompaction("overflow", true);
6772
+ } else {
6773
+ this.#scheduleOverflowRetryContinuation(generation);
6725
6774
  }
6726
6775
  return;
6727
6776
  }
@@ -6732,17 +6781,19 @@ export class AgentSession {
6732
6781
  // Skip if this was an error (non-overflow errors don't have usage data)
6733
6782
  if (assistantMessage.stopReason === "error") return;
6734
6783
  let contextTokens = calculateContextTokens(assistantMessage.usage);
6735
- const maxOutputTokens = this.model?.maxTokens ?? 0;
6784
+ // Model maxTokens is a capability ceiling, not a per-turn reservation.
6785
+ // Auto maintenance should track actual context fullness.
6786
+ const autoCompactionOutputReserveTokens = 0;
6736
6787
  // Cache-epoch invariant: pruning rewrites already-sent toolResult history,
6737
6788
  // which breaks the provider prompt-cache prefix mid-epoch. Only prune at a
6738
6789
  // sanctioned maintenance boundary, i.e. when the un-pruned context already
6739
6790
  // crosses the compaction threshold. Pruning may then avert full compaction.
6740
- if (!shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) return;
6791
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings, autoCompactionOutputReserveTokens)) return;
6741
6792
  const pruneResult = await this.#pruneToolOutputs();
6742
6793
  if (pruneResult) {
6743
6794
  contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
6744
6795
  }
6745
- if (shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) {
6796
+ if (shouldCompact(contextTokens, contextWindow, compactionSettings, autoCompactionOutputReserveTokens)) {
6746
6797
  // Try promotion first — if a larger model is available, switch instead of compacting
6747
6798
  const promoted = await this.#tryContextPromotion(assistantMessage);
6748
6799
  if (!promoted) {
@@ -6820,14 +6871,16 @@ export class AgentSession {
6820
6871
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
6821
6872
 
6822
6873
  let contextTokens = this.#estimateContextTokensForCompaction(pendingMessages).tokens;
6823
- const maxOutputTokens = model.maxTokens ?? 0;
6824
- if (!shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) return;
6874
+ // Model maxTokens is a capability ceiling, not a per-turn reservation.
6875
+ // Auto maintenance should track actual context fullness.
6876
+ const autoCompactionOutputReserveTokens = 0;
6877
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings, autoCompactionOutputReserveTokens)) return;
6825
6878
 
6826
6879
  const pruneResult = await this.#pruneToolOutputs();
6827
6880
  if (pruneResult) {
6828
6881
  contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
6829
6882
  }
6830
- if (shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) {
6883
+ if (shouldCompact(contextTokens, contextWindow, compactionSettings, autoCompactionOutputReserveTokens)) {
6831
6884
  await this.#runAutoCompaction("threshold", false, false, {
6832
6885
  continueAfterMaintenance: false,
6833
6886
  deferHandoffMaintenance: false,
@@ -7730,32 +7783,18 @@ export class AgentSession {
7730
7783
 
7731
7784
  const preparation = prepareCompaction(pathEntries, compactionSettings);
7732
7785
  if (!preparation) {
7786
+ const continuationSkipReason = willRetry ? this.#detectOverflowRetryContinuationSkip() : undefined;
7733
7787
  await this.#emitSessionEvent({
7734
7788
  type: "auto_compaction_end",
7735
7789
  action,
7736
7790
  result: undefined,
7737
7791
  aborted: false,
7738
- willRetry: false,
7792
+ willRetry: willRetry && !continuationSkipReason,
7739
7793
  skipped: true,
7794
+ continuationSkipReason,
7740
7795
  });
7741
7796
  if (willRetry) {
7742
- this.#stripOverflowFailedTurnForRetry();
7743
- if (this.#isResumableAgentTail()) {
7744
- this.#scheduleAgentContinue({
7745
- delayMs: 100,
7746
- generation,
7747
- onSkip: skipReason => this.#logCompactionContinuationSkipped("overflow_retry", skipReason),
7748
- onError: error => this.#logCompactionContinuationError("overflow_retry", error),
7749
- });
7750
- } else {
7751
- const tail = this.agent.state.messages.at(-1) as AssistantMessage | undefined;
7752
- logger.warn("Auto-compaction continuation skipped", {
7753
- source: "overflow_retry",
7754
- reason: "not_resumable_tail",
7755
- role: tail?.role,
7756
- stopReason: tail?.stopReason,
7757
- });
7758
- }
7797
+ this.#scheduleOverflowRetryContinuation(generation);
7759
7798
  } else if (continueAfterMaintenance && reason !== "idle" && this.agent.hasQueuedMessages()) {
7760
7799
  this.#scheduleAgentContinue({
7761
7800
  delayMs: 100,
@@ -7966,26 +8005,18 @@ export class AgentSession {
7966
8005
  details,
7967
8006
  preserveData,
7968
8007
  };
7969
- await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
8008
+ const continuationSkipReason = willRetry ? this.#detectOverflowRetryContinuationSkip() : undefined;
8009
+ await this.#emitSessionEvent({
8010
+ type: "auto_compaction_end",
8011
+ action,
8012
+ result,
8013
+ aborted: false,
8014
+ willRetry: willRetry && !continuationSkipReason,
8015
+ continuationSkipReason,
8016
+ });
7970
8017
 
7971
8018
  if (willRetry) {
7972
- this.#stripOverflowFailedTurnForRetry();
7973
- if (!this.#isResumableAgentTail()) {
7974
- const tail = this.agent.state.messages.at(-1) as AssistantMessage | undefined;
7975
- logger.warn("Auto-compaction continuation skipped", {
7976
- source: "overflow_retry",
7977
- reason: "not_resumable_tail",
7978
- role: tail?.role,
7979
- stopReason: tail?.stopReason,
7980
- });
7981
- } else {
7982
- this.#scheduleAgentContinue({
7983
- delayMs: 100,
7984
- generation,
7985
- onSkip: reason => this.#logCompactionContinuationSkipped("overflow_retry", reason),
7986
- onError: error => this.#logCompactionContinuationError("overflow_retry", error),
7987
- });
7988
- }
8019
+ this.#scheduleOverflowRetryContinuation(generation);
7989
8020
  } else if (continueAfterMaintenance && reason !== "idle" && this.agent.hasQueuedMessages()) {
7990
8021
  // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
7991
8022
  // Kick the loop so queued messages are actually delivered.
package/src/tools/ask.ts CHANGED
@@ -675,8 +675,9 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
675
675
  }
676
676
  try {
677
677
  const deepInterviewPrompt = formatDeepInterviewSelectorPrompt(q.question);
678
+ const isDeepInterviewQuestion = deepInterviewPrompt !== null || q.deepInterview !== undefined;
678
679
  const displayQuestion = deepInterviewPrompt ?? q.question;
679
- const shouldNumberOptions = isDeepInterviewAskQuestion(q.question);
680
+ const shouldNumberOptions = isDeepInterviewQuestion || isDeepInterviewAskQuestion(q.question);
680
681
  const optionLabels = shouldNumberOptions ? numberOptionLabels(rawOptionLabels) : rawOptionLabels;
681
682
  const initialSelection =
682
683
  shouldNumberOptions && options?.previous
@@ -700,7 +701,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
700
701
  signal,
701
702
  initialSelection,
702
703
  navigation: options?.navigation,
703
- scrollTitleRows: deepInterviewPrompt === null ? undefined : DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS,
704
+ scrollTitleRows: isDeepInterviewQuestion ? DEEP_INTERVIEW_SELECTOR_SCROLL_TITLE_ROWS : undefined,
704
705
  otherOptionLabel: shouldNumberOptions
705
706
  ? formatNumberedOptionLabel(OTHER_OPTION, optionLabels.length)
706
707
  : undefined,
@@ -16,6 +16,8 @@ import { renderStatusLine } from "../tui";
16
16
  import { CachedOutputBlock } from "../tui/output-block";
17
17
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
18
18
  import { ensureTool } from "../utils/tools-manager";
19
+ import { INSANE_NOTES, tryInsaneFetch } from "../web/insane/bridge";
20
+ import { validatePublicHttpUrlForInsane } from "../web/insane/url-guard";
19
21
  import { extractWithParallel, findParallelApiKey, getParallelExtractContent } from "../web/parallel";
20
22
  import { specialHandlers } from "../web/scrapers";
21
23
  import type { RenderResult } from "../web/scrapers/types";
@@ -705,6 +707,55 @@ async function handleSpecialUrls(
705
707
  // Main Render Function
706
708
  // =============================================================================
707
709
 
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
+ export async function tryInsaneFallback(args: {
719
+ url: string;
720
+ finalUrl: string;
721
+ timeout: number;
722
+ raw: boolean;
723
+ settings: Settings;
724
+ signal: AbortSignal | undefined;
725
+ fetchedAt: string;
726
+ notes: string[];
727
+ }): Promise<FetchRenderResult | null> {
728
+ if (args.raw) return null;
729
+ 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);
756
+ return null;
757
+ }
758
+
708
759
  /**
709
760
  * Main render function implementing the full pipeline
710
761
  */
@@ -751,6 +802,19 @@ async function renderUrl(
751
802
  throw new ToolAbortError();
752
803
  }
753
804
  if (!response.ok) {
805
+ const failureNote = response.status ? `Failed to fetch URL (HTTP ${response.status})` : "Failed to fetch URL";
806
+ notes.push(failureNote);
807
+ const insane = await tryInsaneFallback({
808
+ url,
809
+ finalUrl: response.finalUrl || url,
810
+ timeout,
811
+ raw,
812
+ settings,
813
+ signal,
814
+ fetchedAt,
815
+ notes,
816
+ });
817
+ if (insane) return insane;
754
818
  return {
755
819
  url,
756
820
  finalUrl: response.finalUrl || url,
@@ -759,7 +823,7 @@ async function renderUrl(
759
823
  content: "",
760
824
  fetchedAt,
761
825
  truncated: false,
762
- notes: [response.status ? `Failed to fetch URL (HTTP ${response.status})` : "Failed to fetch URL"],
826
+ notes,
763
827
  };
764
828
  }
765
829
 
@@ -1062,6 +1126,8 @@ async function renderUrl(
1062
1126
  const htmlResult = await renderHtmlToText(finalUrl, rawContent, timeout, settings, signal, storage);
1063
1127
  if (!htmlResult.ok) {
1064
1128
  notes.push("html rendering failed (lynx/html2text unavailable)");
1129
+ const insane = await tryInsaneFallback({ url, finalUrl, timeout, raw, settings, signal, fetchedAt, notes });
1130
+ if (insane) return insane;
1065
1131
  const output = finalizeOutput(rawContent);
1066
1132
  return {
1067
1133
  url,
@@ -1122,6 +1188,17 @@ async function renderUrl(
1122
1188
  };
1123
1189
  }
1124
1190
 
1191
+ const insaneLowQuality = await tryInsaneFallback({
1192
+ url,
1193
+ finalUrl,
1194
+ timeout,
1195
+ raw,
1196
+ settings,
1197
+ signal,
1198
+ fetchedAt,
1199
+ notes,
1200
+ });
1201
+ if (insaneLowQuality) return insaneLowQuality;
1125
1202
  notes.push("Page appears to require JavaScript or is mostly navigation");
1126
1203
  }
1127
1204
 
@@ -58,6 +58,7 @@ import { SearchToolBm25Tool } from "./search-tool-bm25";
58
58
  import { SkillTool } from "./skill";
59
59
  import { loadSshTool } from "./ssh";
60
60
  import { SubagentTool } from "./subagent";
61
+ import { TelegramSendTool } from "./telegram-send";
61
62
  import { type TodoPhase, TodoWriteTool } from "./todo-write";
62
63
  import { WriteTool } from "./write";
63
64
  import { YieldTool } from "./yield";
@@ -96,6 +97,7 @@ export * from "./search-tool-bm25";
96
97
  export * from "./skill";
97
98
  export * from "./ssh";
98
99
  export * from "./subagent";
100
+ export * from "./telegram-send";
99
101
  export * from "./todo-write";
100
102
  export * from "./vim";
101
103
  export * from "./write";
@@ -402,6 +404,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
402
404
  todo_write: s => new TodoWriteTool(s),
403
405
  web_search: s => new WebSearchTool(s),
404
406
  search_tool_bm25: SearchToolBm25Tool.createIf,
407
+ telegram_send: TelegramSendTool.createIf,
405
408
  write: s => new WriteTool(s),
406
409
  skill: SkillTool.createIf,
407
410
  goal: s => new GoalTool(s),
@@ -0,0 +1,137 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core";
4
+ import { z } from "zod/v4";
5
+ import { getTelegramFileSink } from "../notifications/attachment-registry";
6
+ import { getNotificationConfig, isGloballyConfigured } from "../notifications/config";
7
+ import type { ToolSession } from "./index";
8
+
9
+ const telegramSendSchema = z.object({
10
+ path: z
11
+ .string()
12
+ .describe("file path (absolute or relative to cwd) to send to Telegram; must resolve inside the workspace"),
13
+ caption: z.string().optional().describe("optional caption"),
14
+ });
15
+
16
+ type TelegramSendParams = z.infer<typeof telegramSendSchema>;
17
+
18
+ interface TelegramSendDetails {
19
+ path: string;
20
+ caption?: string;
21
+ ok: boolean;
22
+ error?: string;
23
+ }
24
+
25
+ export class TelegramSendTool implements AgentTool<typeof telegramSendSchema, TelegramSendDetails> {
26
+ readonly name = "telegram_send";
27
+ readonly label = "TelegramSend";
28
+ readonly summary = "Send a workspace file to Telegram";
29
+ readonly loadMode = "discoverable";
30
+ readonly description =
31
+ "Send a file from the current workspace to the connected Telegram chat as a document. The path must resolve " +
32
+ "(after following symlinks) to a regular file inside the project root; paths outside the workspace are rejected.";
33
+ readonly parameters = telegramSendSchema;
34
+ readonly strict = true;
35
+
36
+ constructor(private readonly session: ToolSession) {}
37
+
38
+ static createIf(session: ToolSession): TelegramSendTool | null {
39
+ return isGloballyConfigured(getNotificationConfig(session.settings)) ? new TelegramSendTool(session) : null;
40
+ }
41
+
42
+ /**
43
+ * Resolve `requested` against the workspace root and confine it via realpath:
44
+ * blocks absolute paths outside the project, `..` traversal, and symlinks that
45
+ * escape the root. Returns the resolved real path of a regular file, or an
46
+ * error message. This is the egress safety boundary — the model can only send
47
+ * files that genuinely live inside the session workspace.
48
+ */
49
+ private async resolveContainedFile(
50
+ requested: string,
51
+ ): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
52
+ let root: string;
53
+ try {
54
+ root = await fs.promises.realpath(this.session.cwd);
55
+ } catch {
56
+ return { ok: false, error: "workspace root is unavailable" };
57
+ }
58
+ const absolute = path.isAbsolute(requested) ? requested : path.resolve(root, requested);
59
+ let real: string;
60
+ try {
61
+ real = await fs.promises.realpath(absolute);
62
+ } catch {
63
+ return { ok: false, error: `file not found: ${requested}` };
64
+ }
65
+ const rel = path.relative(root, real);
66
+ if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
67
+ return { ok: false, error: "path escapes the workspace root; only files inside the project can be sent" };
68
+ }
69
+ let stat: fs.Stats;
70
+ try {
71
+ stat = await fs.promises.stat(real);
72
+ } catch {
73
+ return { ok: false, error: `file not found: ${requested}` };
74
+ }
75
+ if (!stat.isFile()) {
76
+ return { ok: false, error: "not a regular file" };
77
+ }
78
+ return { ok: true, path: real };
79
+ }
80
+
81
+ async execute(
82
+ _toolCallId: string,
83
+ params: TelegramSendParams,
84
+ _signal?: AbortSignal,
85
+ _onUpdate?: AgentToolUpdateCallback<TelegramSendDetails>,
86
+ _context?: AgentToolContext,
87
+ ): Promise<AgentToolResult<TelegramSendDetails>> {
88
+ const sessionId = this.session.getSessionId?.();
89
+ if (!sessionId) {
90
+ return {
91
+ content: [{ type: "text", text: "telegram_send: no active session id" }],
92
+ details: { path: params.path, caption: params.caption, ok: false, error: "no active session id" },
93
+ isError: true,
94
+ };
95
+ }
96
+
97
+ const contained = await this.resolveContainedFile(params.path);
98
+ if (!contained.ok) {
99
+ return {
100
+ content: [{ type: "text", text: `telegram_send: ${contained.error}` }],
101
+ details: { path: params.path, caption: params.caption, ok: false, error: contained.error },
102
+ isError: true,
103
+ };
104
+ }
105
+ const abs = contained.path;
106
+
107
+ const sink = getTelegramFileSink(sessionId);
108
+ if (!sink) {
109
+ return {
110
+ content: [
111
+ { type: "text", text: "telegram_send: Telegram notifications are not connected for this session" },
112
+ ],
113
+ details: {
114
+ path: abs,
115
+ caption: params.caption,
116
+ ok: false,
117
+ error: "Telegram notifications are not connected",
118
+ },
119
+ isError: true,
120
+ };
121
+ }
122
+
123
+ const result = await sink({ path: abs, caption: params.caption });
124
+ if (result.ok) {
125
+ return {
126
+ content: [{ type: "text", text: `Sent ${path.basename(abs)} to Telegram.` }],
127
+ details: { path: abs, caption: params.caption, ok: true },
128
+ };
129
+ }
130
+
131
+ return {
132
+ content: [{ type: "text", text: `telegram_send failed: ${result.error}` }],
133
+ details: { path: abs, caption: params.caption, ok: false, error: result.error },
134
+ isError: true,
135
+ };
136
+ }
137
+ }