@gajae-code/coding-agent 0.7.1 → 0.7.3

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 (135) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/types/cli/mcp-cli.d.ts +25 -0
  3. package/dist/types/cli/notify-cli.d.ts +2 -0
  4. package/dist/types/cli.d.ts +6 -0
  5. package/dist/types/commands/mcp.d.ts +70 -0
  6. package/dist/types/config/keybindings.d.ts +2 -2
  7. package/dist/types/config/settings-schema.d.ts +39 -2
  8. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  9. package/dist/types/extensibility/shared-events.d.ts +1 -0
  10. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +1 -1
  11. package/dist/types/lsp/types.d.ts +2 -0
  12. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  13. package/dist/types/modes/components/model-selector.d.ts +2 -0
  14. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  15. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  16. package/dist/types/notifications/attachment-registry.d.ts +17 -0
  17. package/dist/types/notifications/chat-adapters.d.ts +9 -0
  18. package/dist/types/notifications/config.d.ts +9 -1
  19. package/dist/types/notifications/engine.d.ts +59 -0
  20. package/dist/types/notifications/managed-daemon.d.ts +48 -0
  21. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  22. package/dist/types/notifications/telegram-daemon.d.ts +73 -16
  23. package/dist/types/notifications/threaded-inbound.d.ts +19 -0
  24. package/dist/types/notifications/threaded-render.d.ts +6 -1
  25. package/dist/types/notifications/topic-registry.d.ts +2 -0
  26. package/dist/types/session/agent-session.d.ts +2 -0
  27. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  28. package/dist/types/tools/fetch.d.ts +23 -0
  29. package/dist/types/tools/index.d.ts +1 -0
  30. package/dist/types/tools/telegram-send.d.ts +32 -0
  31. package/dist/types/web/insane/bridge.d.ts +103 -0
  32. package/dist/types/web/insane/url-guard.d.ts +25 -0
  33. package/dist/types/web/scrapers/types.d.ts +5 -0
  34. package/dist/types/web/scrapers/utils.d.ts +7 -1
  35. package/dist/types/web/search/provider.d.ts +18 -1
  36. package/dist/types/web/search/providers/insane.d.ts +53 -0
  37. package/dist/types/web/search/providers/text-citations.d.ts +23 -0
  38. package/dist/types/web/search/types.d.ts +12 -4
  39. package/package.json +10 -8
  40. package/scripts/verify-insane-vendor.ts +132 -0
  41. package/src/cli/args.ts +1 -1
  42. package/src/cli/fast-help.ts +1 -1
  43. package/src/cli/mcp-cli.ts +272 -0
  44. package/src/cli/notify-cli.ts +152 -5
  45. package/src/cli.ts +6 -2
  46. package/src/commands/mcp.ts +117 -0
  47. package/src/commands/team.ts +1 -1
  48. package/src/config/keybindings.ts +2 -2
  49. package/src/config/settings-schema.ts +30 -1
  50. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  51. package/src/defaults/gjc/skills/deep-interview/SKILL.md +4 -3
  52. package/src/defaults/gjc/skills/ralplan/SKILL.md +11 -4
  53. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  54. package/src/extensibility/extensions/runner.ts +1 -0
  55. package/src/extensibility/shared-events.ts +1 -0
  56. package/src/gjc-runtime/launch-tmux.ts +17 -3
  57. package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
  58. package/src/gjc-runtime/ralplan-runtime.ts +2 -2
  59. package/src/gjc-runtime/tmux-common.ts +3 -1
  60. package/src/gjc-runtime/ultragoal-guard.ts +25 -8
  61. package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
  62. package/src/gjc-runtime/workflow-manifest.ts +7 -2
  63. package/src/hooks/skill-state.ts +57 -0
  64. package/src/internal-urls/docs-index.generated.ts +14 -11
  65. package/src/lsp/config.ts +16 -3
  66. package/src/lsp/defaults.json +7 -0
  67. package/src/lsp/types.ts +2 -0
  68. package/src/modes/bridge/bridge-mode.ts +11 -0
  69. package/src/modes/components/custom-editor.ts +2 -0
  70. package/src/modes/components/footer.ts +2 -3
  71. package/src/modes/components/model-selector.ts +12 -0
  72. package/src/modes/components/status-line/git-utils.ts +25 -0
  73. package/src/modes/components/status-line.ts +10 -11
  74. package/src/modes/components/welcome.ts +2 -3
  75. package/src/modes/controllers/event-controller.ts +15 -0
  76. package/src/modes/controllers/selector-controller.ts +3 -0
  77. package/src/modes/interactive-mode.ts +48 -3
  78. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  79. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  80. package/src/modes/theme/defaults/index.ts +2 -0
  81. package/src/modes/utils/context-usage.ts +2 -2
  82. package/src/notifications/attachment-registry.ts +23 -0
  83. package/src/notifications/chat-adapters.ts +147 -0
  84. package/src/notifications/config.ts +23 -2
  85. package/src/notifications/engine.ts +100 -0
  86. package/src/notifications/index.ts +180 -38
  87. package/src/notifications/managed-daemon.ts +163 -0
  88. package/src/notifications/operator-runtime.ts +171 -0
  89. package/src/notifications/telegram-daemon.ts +553 -236
  90. package/src/notifications/threaded-inbound.ts +60 -4
  91. package/src/notifications/threaded-render.ts +20 -2
  92. package/src/notifications/topic-registry.ts +5 -0
  93. package/src/session/agent-session.ts +82 -51
  94. package/src/slash-commands/helpers/parse.ts +2 -1
  95. package/src/tools/bash.ts +9 -0
  96. package/src/tools/composer-bash-policy.ts +96 -0
  97. package/src/tools/fetch.ts +94 -1
  98. package/src/tools/index.ts +3 -0
  99. package/src/tools/telegram-send.ts +137 -0
  100. package/src/web/insane/bridge.ts +350 -0
  101. package/src/web/insane/url-guard.ts +159 -0
  102. package/src/web/scrapers/types.ts +143 -45
  103. package/src/web/scrapers/utils.ts +70 -19
  104. package/src/web/search/provider.ts +77 -18
  105. package/src/web/search/providers/anthropic.ts +70 -3
  106. package/src/web/search/providers/codex.ts +1 -119
  107. package/src/web/search/providers/gemini.ts +99 -0
  108. package/src/web/search/providers/insane.ts +551 -0
  109. package/src/web/search/providers/openai-compatible.ts +66 -32
  110. package/src/web/search/providers/text-citations.ts +111 -0
  111. package/src/web/search/types.ts +13 -2
  112. package/vendor/insane-search/LICENSE +21 -0
  113. package/vendor/insane-search/MANIFEST.json +24 -0
  114. package/vendor/insane-search/engine/__init__.py +23 -0
  115. package/vendor/insane-search/engine/__main__.py +128 -0
  116. package/vendor/insane-search/engine/bias_check.py +183 -0
  117. package/vendor/insane-search/engine/executor.py +254 -0
  118. package/vendor/insane-search/engine/fetch_chain.py +725 -0
  119. package/vendor/insane-search/engine/learning.py +175 -0
  120. package/vendor/insane-search/engine/phase0.py +214 -0
  121. package/vendor/insane-search/engine/safety.py +91 -0
  122. package/vendor/insane-search/engine/templates/package.json +11 -0
  123. package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
  124. package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
  125. package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
  126. package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
  127. package/vendor/insane-search/engine/tests/test_u1.py +200 -0
  128. package/vendor/insane-search/engine/tests/test_u4.py +131 -0
  129. package/vendor/insane-search/engine/tests/test_u5.py +163 -0
  130. package/vendor/insane-search/engine/tests/test_u7.py +124 -0
  131. package/vendor/insane-search/engine/transport.py +211 -0
  132. package/vendor/insane-search/engine/url_transforms.py +98 -0
  133. package/vendor/insane-search/engine/validators.py +331 -0
  134. package/vendor/insane-search/engine/waf_detector.py +214 -0
  135. package/vendor/insane-search/engine/waf_profiles.yaml +162 -0
@@ -0,0 +1,147 @@
1
+ import type {
2
+ NotificationAdapterPayload,
3
+ NotificationEvent,
4
+ NotificationPresentationAdapter,
5
+ NotificationReplyRoute,
6
+ } from "./engine";
7
+ import { truncate } from "./helpers";
8
+
9
+ type AdapterKind = "discord" | "slack";
10
+
11
+ interface ChatAdapterOptions {
12
+ kind: AdapterKind;
13
+ channelId?: string;
14
+ }
15
+
16
+ interface InboundShape {
17
+ sessionId?: unknown;
18
+ actionId?: unknown;
19
+ answer?: unknown;
20
+ text?: unknown;
21
+ value?: unknown;
22
+ }
23
+
24
+ function text(value: unknown): string | undefined {
25
+ return typeof value === "string" && value.length > 0 ? value : undefined;
26
+ }
27
+
28
+ function publicLine(label: string, value: unknown): string | undefined {
29
+ const v = text(value);
30
+ return v ? `${label}: ${truncate(v, 280)}` : undefined;
31
+ }
32
+
33
+ function actionText(event: Extract<NotificationEvent, { type: "action_needed" }>, format: "discord" | "slack"): string {
34
+ if (event.kind === "idle") {
35
+ const summary = text(event.summary);
36
+ return summary ? `Agent idle\n${truncate(summary, 1200)}` : "Agent idle";
37
+ }
38
+ const question = truncate(text(event.question) ?? "Question", 1200);
39
+ const options = Array.isArray(event.options) ? event.options.map(option => String(option)) : [];
40
+ const lines = [`Question: ${question}`];
41
+ if (options.length > 0) {
42
+ lines.push(
43
+ ...options.map((option, index) => {
44
+ const label = truncate(option, 180);
45
+ return format === "slack" ? `${index + 1}. ${label}` : `**${index + 1}.** ${label}`;
46
+ }),
47
+ );
48
+ } else {
49
+ lines.push("Reply with text.");
50
+ }
51
+ return lines.join("\n");
52
+ }
53
+
54
+ function frameText(event: Extract<NotificationEvent, { type: "frame" }>): string | undefined {
55
+ const frame = event.frame;
56
+ const kind = text(frame.type);
57
+ if (!kind) return undefined;
58
+ const lines = [`GJC ${kind.replace(/_/g, " ")}`];
59
+ for (const line of [
60
+ publicLine("title", frame.title),
61
+ publicLine("repo", frame.repo),
62
+ publicLine("branch", frame.branch),
63
+ publicLine("task", frame.task),
64
+ publicLine("goal", frame.goal),
65
+ publicLine("model", frame.model),
66
+ ]) {
67
+ if (line) lines.push(line);
68
+ }
69
+ const body = text(frame.text) ?? text(frame.lastMessage) ?? text(frame.caption);
70
+ if (body) lines.push(truncate(body, 1800));
71
+ return lines.join("\n");
72
+ }
73
+
74
+ function routeFromInbound(input: unknown): NotificationReplyRoute | undefined {
75
+ const raw = input as InboundShape;
76
+ if (!raw || typeof raw !== "object") return undefined;
77
+ const sessionId = text(raw.sessionId);
78
+ const actionId = text(raw.actionId);
79
+ if (!sessionId || !actionId) return undefined;
80
+ const answer = raw.answer ?? raw.value ?? raw.text;
81
+ if (typeof answer !== "string" && typeof answer !== "number" && typeof answer !== "object") return undefined;
82
+ return { sessionId, actionId, answer: answer as NotificationReplyRoute["answer"] };
83
+ }
84
+
85
+ class DiscordNotificationAdapter implements NotificationPresentationAdapter {
86
+ readonly kind = "discord" as const;
87
+ constructor(private readonly opts: ChatAdapterOptions) {}
88
+
89
+ render(event: NotificationEvent): NotificationAdapterPayload[] {
90
+ if (event.type === "action_resolved") return [];
91
+ const content = event.type === "action_needed" ? actionText(event, "discord") : frameText(event);
92
+ if (!content) return [];
93
+ const payload: Record<string, unknown> = {
94
+ content,
95
+ allowed_mentions: { parse: [] },
96
+ };
97
+ if (this.opts.channelId) payload.channel_id = this.opts.channelId;
98
+ return [
99
+ {
100
+ adapter: this.kind,
101
+ channelKey: this.opts.channelId,
102
+ body: payload,
103
+ route: event.type === "action_needed" ? { sessionId: event.sessionId, actionId: event.id } : undefined,
104
+ },
105
+ ];
106
+ }
107
+
108
+ mapInbound(input: unknown): NotificationReplyRoute | undefined {
109
+ return routeFromInbound(input);
110
+ }
111
+ }
112
+
113
+ class SlackNotificationAdapter implements NotificationPresentationAdapter {
114
+ readonly kind = "slack" as const;
115
+ constructor(private readonly opts: ChatAdapterOptions) {}
116
+
117
+ render(event: NotificationEvent): NotificationAdapterPayload[] {
118
+ if (event.type === "action_resolved") return [];
119
+ const textValue = event.type === "action_needed" ? actionText(event, "slack") : frameText(event);
120
+ if (!textValue) return [];
121
+ const payload: Record<string, unknown> = {
122
+ text: textValue,
123
+ mrkdwn: true,
124
+ };
125
+ if (this.opts.channelId) payload.channel = this.opts.channelId;
126
+ return [
127
+ {
128
+ adapter: this.kind,
129
+ channelKey: this.opts.channelId,
130
+ body: payload,
131
+ route: event.type === "action_needed" ? { sessionId: event.sessionId, actionId: event.id } : undefined,
132
+ },
133
+ ];
134
+ }
135
+
136
+ mapInbound(input: unknown): NotificationReplyRoute | undefined {
137
+ return routeFromInbound(input);
138
+ }
139
+ }
140
+
141
+ export function createDiscordAdapter(opts: Omit<ChatAdapterOptions, "kind"> = {}): NotificationPresentationAdapter {
142
+ return new DiscordNotificationAdapter({ ...opts, kind: "discord" });
143
+ }
144
+
145
+ export function createSlackAdapter(opts: Omit<ChatAdapterOptions, "kind"> = {}): NotificationPresentationAdapter {
146
+ return new SlackNotificationAdapter({ ...opts, kind: "slack" });
147
+ }
@@ -5,6 +5,14 @@ export interface NotificationConfig {
5
5
  enabled: boolean;
6
6
  botToken?: string;
7
7
  chatId?: string;
8
+ discord: {
9
+ botToken?: string;
10
+ channelId?: string;
11
+ };
12
+ slack: {
13
+ botToken?: string;
14
+ channelId?: string;
15
+ };
8
16
  redact: boolean;
9
17
  verbosity: "lean" | "verbose";
10
18
  idleTimeoutMs: number;
@@ -16,15 +24,28 @@ export function getNotificationConfig(settings: Settings): NotificationConfig {
16
24
  enabled: settings.get("notifications.enabled"),
17
25
  botToken: settings.get("notifications.telegram.botToken"),
18
26
  chatId: settings.get("notifications.telegram.chatId"),
27
+ discord: {
28
+ botToken: settings.get("notifications.discord.botToken"),
29
+ channelId: settings.get("notifications.discord.channelId"),
30
+ },
31
+ slack: {
32
+ botToken: settings.get("notifications.slack.botToken"),
33
+ channelId: settings.get("notifications.slack.channelId"),
34
+ },
19
35
  redact: settings.get("notifications.redact"),
20
36
  verbosity: settings.get("notifications.verbosity") === "verbose" ? "verbose" : "lean",
21
37
  idleTimeoutMs: settings.get("notifications.daemon.idleTimeoutMs"),
22
38
  };
23
39
  }
24
40
 
25
- /** Is global config sufficient for auto-on (enabled + botToken + chatId all present)? */
41
+ /** Is global config sufficient for auto-on (enabled + at least one configured adapter)? */
26
42
  export function isGloballyConfigured(cfg: NotificationConfig): boolean {
27
- return cfg.enabled && Boolean(cfg.botToken) && Boolean(cfg.chatId);
43
+ return (
44
+ cfg.enabled &&
45
+ ((Boolean(cfg.botToken) && Boolean(cfg.chatId)) ||
46
+ (Boolean(cfg.discord.botToken) && Boolean(cfg.discord.channelId)) ||
47
+ (Boolean(cfg.slack.botToken) && Boolean(cfg.slack.channelId)))
48
+ );
28
49
  }
29
50
 
30
51
  /** Resolve whether the notifications extension should be registered at SDK startup. */
@@ -0,0 +1,100 @@
1
+ import { buildRedactedAction, type RedactableAction } from "./config";
2
+
3
+ export type NotificationEvent =
4
+ | ({ type: "action_needed" } & RedactableAction)
5
+ | { type: "action_resolved"; id: string; sessionId: string; resolvedBy?: string }
6
+ | { type: "frame"; sessionId: string; frame: Record<string, unknown> };
7
+
8
+ export interface NotificationReplyRoute {
9
+ sessionId: string;
10
+ actionId: string;
11
+ answer: number | string | { selected?: Array<number | string>; custom?: string };
12
+ }
13
+
14
+ export interface NotificationAdapterPayload {
15
+ adapter: string;
16
+ channelKey?: string;
17
+ body: unknown;
18
+ route?: Omit<NotificationReplyRoute, "answer">;
19
+ }
20
+
21
+ export interface NotificationPresentationAdapter {
22
+ readonly kind: "telegram" | "discord" | "slack";
23
+ render(event: NotificationEvent): NotificationAdapterPayload[];
24
+ mapInbound(input: unknown): NotificationReplyRoute | undefined;
25
+ }
26
+
27
+ export interface EngineSessionSink {
28
+ sendReply(route: NotificationReplyRoute): void;
29
+ }
30
+
31
+ export interface NotificationEngineOptions {
32
+ redact: boolean;
33
+ sessionTag: (sessionId: string) => string;
34
+ }
35
+
36
+ /**
37
+ * Shared presentation engine for managed notification clients.
38
+ *
39
+ * It owns fanout, redaction boundaries, pending-action routing, and reply
40
+ * delivery into session sinks. Transport adapters stay pure: render an internal
41
+ * event into a public-safe payload and map an inbound transport interaction
42
+ * back into a session/action answer.
43
+ */
44
+ export class NotificationPresentationEngine {
45
+ readonly adapters: readonly NotificationPresentationAdapter[];
46
+ private readonly sessions = new Map<string, EngineSessionSink>();
47
+ private readonly pending = new Map<string, { sessionId: string; actionId: string }>();
48
+
49
+ constructor(
50
+ adapters: readonly NotificationPresentationAdapter[],
51
+ private readonly opts: NotificationEngineOptions,
52
+ ) {
53
+ this.adapters = adapters;
54
+ }
55
+
56
+ connectSession(sessionId: string, sink: EngineSessionSink): void {
57
+ this.sessions.set(sessionId, sink);
58
+ }
59
+
60
+ dropSession(sessionId: string): void {
61
+ this.sessions.delete(sessionId);
62
+ for (const [key, route] of this.pending) {
63
+ if (route.sessionId === sessionId) this.pending.delete(key);
64
+ }
65
+ }
66
+
67
+ fanout(event: NotificationEvent): NotificationAdapterPayload[] {
68
+ const safeEvent = this.redactEvent(event);
69
+ if (safeEvent.type === "action_needed" && safeEvent.kind === "ask") {
70
+ this.pending.set(safeEvent.id, { sessionId: safeEvent.sessionId, actionId: safeEvent.id });
71
+ }
72
+ if (safeEvent.type === "action_resolved") {
73
+ this.pending.delete(safeEvent.id);
74
+ }
75
+ return this.adapters.flatMap(adapter => adapter.render(safeEvent));
76
+ }
77
+
78
+ routeInbound(adapterKind: NotificationPresentationAdapter["kind"], input: unknown): boolean {
79
+ const adapter = this.adapters.find(candidate => candidate.kind === adapterKind);
80
+ const route = adapter?.mapInbound(input);
81
+ if (!route) return false;
82
+ const pending = this.pending.get(route.actionId);
83
+ if (!pending || pending.sessionId !== route.sessionId) return false;
84
+ const sink = this.sessions.get(route.sessionId);
85
+ if (!sink) return false;
86
+ sink.sendReply(route);
87
+ return true;
88
+ }
89
+
90
+ private redactEvent(event: NotificationEvent): NotificationEvent {
91
+ if (event.type !== "action_needed") return event;
92
+ return {
93
+ ...buildRedactedAction(event, {
94
+ redact: this.opts.redact,
95
+ sessionTag: this.opts.sessionTag(event.sessionId),
96
+ }),
97
+ type: "action_needed",
98
+ };
99
+ }
100
+ }
@@ -24,11 +24,13 @@ import * as fs from "node:fs";
24
24
  import * as os from "node:os";
25
25
  import * as path from "node:path";
26
26
  import { promisify } from "node:util";
27
+ import type { ImageContent, TextContent } from "@gajae-code/ai";
27
28
  import { NotificationServer } from "@gajae-code/natives";
28
29
  import { logger } from "@gajae-code/utils";
29
30
  import { Settings } from "../config/settings";
30
31
  import type { ExtensionCommandContext, ExtensionContext, ExtensionFactory } from "../extensibility/extensions";
31
32
  import { registerAskAnswerSource } from "../tools/ask-answer-registry";
33
+ import { registerTelegramFileSink } from "./attachment-registry";
32
34
  import {
33
35
  getNotificationConfig,
34
36
  isGloballyConfigured,
@@ -136,6 +138,8 @@ interface SessionRuntime {
136
138
  pendingInteractive: Map<string, PendingInteractiveAsk>;
137
139
  /** Deregisters this session's ask answer source. */
138
140
  disposeAnswerSource: () => void;
141
+ /** Deregisters this session's Telegram file sink. */
142
+ disposeFileSink: () => void;
139
143
  redact: boolean;
140
144
  sessionTag: string;
141
145
  /** Whether the agent loop is currently running (drives the typing indicator). */
@@ -157,6 +161,16 @@ interface ResolvedSettings {
157
161
 
158
162
  const defaultConfig: NotificationConfig = {
159
163
  enabled: false,
164
+ botToken: undefined,
165
+ chatId: undefined,
166
+ discord: {
167
+ botToken: undefined,
168
+ channelId: undefined,
169
+ },
170
+ slack: {
171
+ botToken: undefined,
172
+ channelId: undefined,
173
+ },
160
174
  redact: false,
161
175
  verbosity: "lean",
162
176
  idleTimeoutMs: 60_000,
@@ -228,6 +242,56 @@ function mapAnswerToGate(
228
242
  return { selected: [] };
229
243
  }
230
244
 
245
+ /** Register the interactive `ask` answer source for a session (the ask tool
246
+ * races the local UI against a remote reply). Returns the deregister disposer. */
247
+ function registerInteractiveAnswerSource(
248
+ id: string,
249
+ server: NotificationServer,
250
+ pendingInteractive: Map<string, PendingInteractiveAsk>,
251
+ redact: boolean,
252
+ tag: string,
253
+ ): () => void {
254
+ return registerAskAnswerSource(id, {
255
+ awaitAnswer(question, options, signal) {
256
+ if (signal?.aborted) return Promise.resolve(undefined);
257
+ const askId = `ask:${crypto.randomUUID()}`;
258
+ try {
259
+ server.registerAsk(
260
+ JSON.stringify(
261
+ notificationActionPayload(
262
+ { id: askId, kind: "ask", sessionId: id, question, options },
263
+ { redact, sessionTag: tag },
264
+ ),
265
+ ),
266
+ true,
267
+ );
268
+ } catch (e) {
269
+ logger.warn(`notifications: registerAsk failed: ${String(e)}`);
270
+ return Promise.resolve(undefined);
271
+ }
272
+ return new Promise<string | undefined>(resolve => {
273
+ pendingInteractive.set(askId, { resolve, options });
274
+ signal?.addEventListener("abort", () => {
275
+ if (!pendingInteractive.delete(askId)) return;
276
+ // Local UI answered: mark the remote action resolved-locally.
277
+ try {
278
+ server.resolveLocal(askId, undefined);
279
+ } catch {}
280
+ resolve(undefined);
281
+ });
282
+ });
283
+ },
284
+ });
285
+ }
286
+
287
+ /** Extract the session id from a `<timestamp>_<uuid>.jsonl` session file path. */
288
+ function sessionIdFromFile(file: string | undefined): string | undefined {
289
+ if (!file) return undefined;
290
+ const base = path.basename(file).replace(/\.jsonl$/, "");
291
+ const underscore = base.indexOf("_");
292
+ return underscore >= 0 ? base.slice(underscore + 1) : undefined;
293
+ }
294
+
231
295
  export const createNotificationsExtension: ExtensionFactory = api => {
232
296
  const runtimes = new Map<string, SessionRuntime>();
233
297
  const disabledSessions = new Set<string>();
@@ -239,6 +303,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
239
303
  runtimes.delete(id);
240
304
  try {
241
305
  rt.disposeAnswerSource();
306
+ rt.disposeFileSink();
242
307
  } catch {}
243
308
  // Resolve any still-pending interactive asks so the ask tool is not left hanging.
244
309
  for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
@@ -271,6 +336,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
271
336
  const pendingInteractive = new Map<string, PendingInteractiveAsk>();
272
337
  const tag = sessionTag(id);
273
338
  const redact = cfg.redact;
339
+ let runtime: SessionRuntime | undefined;
274
340
 
275
341
  // The SDK can always answer now (interactive via the answer source, or the
276
342
  // unattended gate), so the endpoint advertises a resolver.
@@ -317,23 +383,34 @@ export const createNotificationsExtension: ExtensionFactory = api => {
317
383
  // thread (forwarded by the daemon over the WS, fail-closed at the daemon).
318
384
  server.onInbound((err, inbound) => {
319
385
  if (err || !inbound) return;
320
- if (inbound.kind === "user_message" && inbound.text) {
386
+ if (inbound.kind === "user_message") {
321
387
  // Inject as a user turn (steers/continues the agent; the resulting
322
388
  // turn streams back via the turn_end handler even when not idle).
323
389
  // Record the update id so it can be acked as "consumed" on the next
324
390
  // turn_start, and steer (vs start a fresh turn) when already busy.
325
- const rt = runtimes.get(id);
326
- if (rt && typeof inbound.updateId === "number") rt.pendingInbound.add(inbound.updateId);
391
+ const text = inbound.text ?? "";
392
+ const images = inbound.images ?? [];
393
+ if (!text && images.length === 0) return;
394
+ if (runtime && typeof inbound.updateId === "number") runtime.pendingInbound.add(inbound.updateId);
395
+ const content: string | (TextContent | ImageContent)[] =
396
+ images.length > 0
397
+ ? [
398
+ ...(text ? [{ type: "text", text } as TextContent] : []),
399
+ ...images.map(
400
+ img =>
401
+ ({ type: "image", data: img.data, mimeType: img.mime ?? "image/jpeg" }) as ImageContent,
402
+ ),
403
+ ]
404
+ : text;
327
405
  try {
328
- api.sendUserMessage(inbound.text, rt?.busy ? { deliverAs: "steer" } : undefined);
406
+ api.sendUserMessage(content, runtime?.busy ? { deliverAs: "steer" } : undefined);
329
407
  } catch (e) {
330
408
  logger.warn(`notifications: sendUserMessage failed: ${String(e)}`);
331
409
  }
332
410
  return;
333
411
  }
334
412
  if (inbound.kind === "config_command") {
335
- const rt = runtimes.get(id);
336
- if (rt && typeof inbound.redact === "boolean") rt.redact = inbound.redact;
413
+ if (runtime && typeof inbound.redact === "boolean") runtime.redact = inbound.redact;
337
414
  }
338
415
  });
339
416
 
@@ -341,48 +418,37 @@ export const createNotificationsExtension: ExtensionFactory = api => {
341
418
  const endpoint = await server.start();
342
419
 
343
420
  // Interactive answer source: the ask tool races the local UI against this.
344
- const disposeAnswerSource = registerAskAnswerSource(id, {
345
- awaitAnswer(question, options, signal) {
346
- if (signal?.aborted) return Promise.resolve(undefined);
347
- const askId = `ask:${crypto.randomUUID()}`;
348
- try {
349
- server.registerAsk(
350
- JSON.stringify(
351
- notificationActionPayload(
352
- { id: askId, kind: "ask", sessionId: id, question, options },
353
- { redact, sessionTag: tag },
354
- ),
355
- ),
356
- true,
357
- );
358
- } catch (e) {
359
- logger.warn(`notifications: registerAsk failed: ${String(e)}`);
360
- return Promise.resolve(undefined);
361
- }
362
- return new Promise<string | undefined>(resolve => {
363
- pendingInteractive.set(askId, { resolve, options });
364
- signal?.addEventListener("abort", () => {
365
- if (!pendingInteractive.delete(askId)) return;
366
- // Local UI answered: mark the remote action resolved-locally.
367
- try {
368
- server.resolveLocal(askId, undefined);
369
- } catch {}
370
- resolve(undefined);
371
- });
372
- });
373
- },
421
+ const disposeAnswerSource = registerInteractiveAnswerSource(id, server, pendingInteractive, redact, tag);
422
+ const disposeFileSink = registerTelegramFileSink(id, async file => {
423
+ try {
424
+ const data = await fs.promises.readFile(file.path);
425
+ server.pushFrame(
426
+ JSON.stringify({
427
+ type: "file_attachment",
428
+ sessionId: id,
429
+ name: path.basename(file.path),
430
+ data: data.toString("base64"),
431
+ caption: file.caption,
432
+ }),
433
+ );
434
+ return { ok: true };
435
+ } catch (e) {
436
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
437
+ }
374
438
  });
375
439
 
376
- runtimes.set(id, {
440
+ runtime = {
377
441
  server,
378
442
  idleSeq: 0,
379
443
  pendingInteractive,
380
444
  disposeAnswerSource,
445
+ disposeFileSink,
381
446
  redact,
382
447
  sessionTag: tag,
383
448
  busy: false,
384
449
  pendingInbound: new Set<number>(),
385
- });
450
+ };
451
+ runtimes.set(id, runtime);
386
452
  logger.info(`notifications: serving session ${id} at ${endpoint.url} (unattended=${unattended})`);
387
453
 
388
454
  if (settingsAvailable && settings && isGloballyConfigured(cfg)) {
@@ -512,6 +578,82 @@ export const createNotificationsExtension: ExtensionFactory = api => {
512
578
  await startSession(ctx);
513
579
  });
514
580
 
581
+ // A session id change within the same process needs reason-aware handling.
582
+ // `/new` and fork CONTINUE the same terminal thread (e.g. plan "approve and
583
+ // execute" clears into a fresh session), so re-key the existing runtime
584
+ // old→new WITHOUT recreating the NotificationServer: the server, its endpoint
585
+ // discovery file, and the daemon's forum topic are all keyed by the original
586
+ // session id and the daemon routes by socket, so the existing topic is reused
587
+ // and the next identity frame renames it in place instead of spawning a new
588
+ // thread. `resume`, by contrast, loads a DIFFERENT, already-persisted session
589
+ // that owns its own topic — tear the previous runtime down and start fresh
590
+ // under the resumed id so the daemon attaches to (or recreates) that
591
+ // session's own discovery + topic rather than hijacking this terminal's.
592
+ api.on("session_switch", async (event, ctx) => {
593
+ const newId = sessionId(ctx);
594
+ const prevId = sessionIdFromFile(event.previousSessionFile);
595
+ if (!prevId || prevId === newId) return;
596
+
597
+ if (event.reason === "resume") {
598
+ stopSession(prevId);
599
+ await startSession(ctx);
600
+ return;
601
+ }
602
+
603
+ // `/new` / fork: re-key in place and rename the existing topic.
604
+ if (disabledSessions.delete(prevId)) disabledSessions.add(newId);
605
+ const rt = runtimes.get(prevId);
606
+ if (!rt || runtimes.has(newId)) return;
607
+ runtimes.delete(prevId);
608
+ runtimes.set(newId, rt);
609
+ // Re-bind the interactive ask answer source: the ask tool resolves the
610
+ // source by the current session id, which just changed.
611
+ try {
612
+ rt.disposeAnswerSource();
613
+ rt.disposeFileSink();
614
+ } catch {}
615
+ rt.disposeAnswerSource = registerInteractiveAnswerSource(
616
+ newId,
617
+ rt.server,
618
+ rt.pendingInteractive,
619
+ rt.redact,
620
+ rt.sessionTag,
621
+ );
622
+ rt.disposeFileSink = registerTelegramFileSink(newId, async file => {
623
+ try {
624
+ const data = await fs.promises.readFile(file.path);
625
+ rt.server.pushFrame(
626
+ JSON.stringify({
627
+ type: "file_attachment",
628
+ sessionId: newId,
629
+ name: path.basename(file.path),
630
+ data: data.toString("base64"),
631
+ caption: file.caption,
632
+ }),
633
+ );
634
+ return { ok: true };
635
+ } catch (e) {
636
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
637
+ }
638
+ });
639
+ // Rename the existing topic now when the new session already has a name; a
640
+ // fresh unnamed session is renamed on its next agent_end re-assert, which
641
+ // avoids a transient rename to bare "repo/branch".
642
+ if (ctx.sessionManager.getSessionName()) {
643
+ try {
644
+ rt.server.pushFrame(
645
+ JSON.stringify({
646
+ type: "identity_header",
647
+ sessionId: newId,
648
+ ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
649
+ }),
650
+ );
651
+ } catch (e) {
652
+ logger.warn(`notifications: identity_header (switch) failed: ${String(e)}`);
653
+ }
654
+ }
655
+ });
656
+
515
657
  // Drive the live typing indicator: mark busy when the agent loop starts so
516
658
  // the daemon shows "typing…" in the thread while the agent is thinking,
517
659
  // before any turn output exists. Cleared on `agent_end` below.