@gajae-code/coding-agent 0.6.5 → 0.7.0

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 (127) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/async/job-manager.d.ts +3 -1
  3. package/dist/types/cli/daemon-cli.d.ts +25 -0
  4. package/dist/types/cli/notify-cli.d.ts +23 -0
  5. package/dist/types/cli/setup-cli.d.ts +20 -1
  6. package/dist/types/commands/daemon.d.ts +41 -0
  7. package/dist/types/commands/notify.d.ts +41 -0
  8. package/dist/types/config/model-profile-activation.d.ts +12 -0
  9. package/dist/types/config/model-profiles.d.ts +2 -1
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/models-config-schema.d.ts +5 -0
  12. package/dist/types/config/settings-schema.d.ts +38 -0
  13. package/dist/types/coordinator/contract.d.ts +1 -1
  14. package/dist/types/daemon/builtin.d.ts +20 -0
  15. package/dist/types/daemon/control-types.d.ts +57 -0
  16. package/dist/types/daemon/runtime.d.ts +25 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  18. package/dist/types/gjc-runtime/state-writer.d.ts +2 -0
  19. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +15 -0
  20. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +14 -0
  21. package/dist/types/modes/components/oauth-selector.d.ts +2 -0
  22. package/dist/types/modes/controllers/selector-controller.d.ts +2 -2
  23. package/dist/types/modes/interactive-mode.d.ts +1 -1
  24. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
  25. package/dist/types/modes/types.d.ts +7 -1
  26. package/dist/types/notifications/config-commands.d.ts +26 -0
  27. package/dist/types/notifications/config.d.ts +61 -0
  28. package/dist/types/notifications/helpers.d.ts +55 -0
  29. package/dist/types/notifications/html-format.d.ts +62 -0
  30. package/dist/types/notifications/index.d.ts +28 -0
  31. package/dist/types/notifications/rate-limit-pool.d.ts +93 -0
  32. package/dist/types/notifications/telegram-cli.d.ts +19 -0
  33. package/dist/types/notifications/telegram-daemon-cli.d.ts +11 -0
  34. package/dist/types/notifications/telegram-daemon-control.d.ts +56 -0
  35. package/dist/types/notifications/telegram-daemon.d.ts +276 -0
  36. package/dist/types/notifications/telegram-reference.d.ts +111 -0
  37. package/dist/types/notifications/threaded-inbound.d.ts +58 -0
  38. package/dist/types/notifications/threaded-render.d.ts +66 -0
  39. package/dist/types/notifications/topic-registry.d.ts +67 -0
  40. package/dist/types/rlm/index.d.ts +12 -0
  41. package/dist/types/session/agent-session.d.ts +39 -2
  42. package/dist/types/session/auth-storage.d.ts +1 -1
  43. package/dist/types/setup/credential-auto-import.d.ts +63 -0
  44. package/dist/types/setup/credential-import.d.ts +3 -0
  45. package/dist/types/setup/host-plugin-setup.d.ts +39 -0
  46. package/dist/types/tools/ask-answer-registry.d.ts +13 -0
  47. package/dist/types/tools/index.d.ts +18 -0
  48. package/dist/types/tools/subagent.d.ts +3 -0
  49. package/package.json +7 -7
  50. package/scripts/build-binary.ts +3 -0
  51. package/src/async/job-manager.ts +5 -1
  52. package/src/cli/daemon-cli.ts +122 -0
  53. package/src/cli/notify-cli.ts +274 -0
  54. package/src/cli/setup-cli.ts +173 -84
  55. package/src/cli.ts +2 -0
  56. package/src/commands/daemon.ts +47 -0
  57. package/src/commands/notify.ts +61 -0
  58. package/src/commands/setup.ts +11 -1
  59. package/src/config/model-profile-activation.ts +74 -5
  60. package/src/config/model-profiles.ts +7 -4
  61. package/src/config/model-registry.ts +6 -3
  62. package/src/config/models-config-schema.ts +1 -1
  63. package/src/config/settings-schema.ts +29 -0
  64. package/src/coordinator/contract.ts +3 -0
  65. package/src/coordinator-mcp/server.ts +270 -1
  66. package/src/daemon/builtin.ts +46 -0
  67. package/src/daemon/control-types.ts +65 -0
  68. package/src/daemon/runtime.ts +51 -0
  69. package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
  70. package/src/extensibility/extensions/runner.ts +4 -0
  71. package/src/extensibility/extensions/types.ts +8 -0
  72. package/src/gjc-runtime/deep-interview-recorder.ts +12 -4
  73. package/src/gjc-runtime/state-runtime.ts +18 -4
  74. package/src/gjc-runtime/state-writer.ts +8 -8
  75. package/src/gjc-runtime/ultragoal-guard.ts +57 -2
  76. package/src/gjc-runtime/ultragoal-runtime.ts +105 -19
  77. package/src/gjc-runtime/workflow-manifest.generated.json +27 -2
  78. package/src/gjc-runtime/workflow-manifest.ts +11 -1
  79. package/src/goals/tools/goal-tool.ts +11 -2
  80. package/src/internal-urls/docs-index.generated.ts +6 -5
  81. package/src/main.ts +30 -0
  82. package/src/modes/acp/acp-event-mapper.ts +1 -0
  83. package/src/modes/components/hook-editor.ts +7 -2
  84. package/src/modes/components/oauth-selector.ts +19 -0
  85. package/src/modes/controllers/event-controller.ts +20 -0
  86. package/src/modes/controllers/selector-controller.ts +80 -17
  87. package/src/modes/interactive-mode.ts +6 -2
  88. package/src/modes/runtime-init.ts +1 -0
  89. package/src/modes/shared/agent-wire/event-contract.ts +1 -0
  90. package/src/modes/shared/agent-wire/event-envelope.ts +1 -0
  91. package/src/modes/shared/agent-wire/event-observation.ts +16 -0
  92. package/src/modes/shared/agent-wire/unattended-session.ts +22 -0
  93. package/src/modes/types.ts +7 -1
  94. package/src/modes/utils/ui-helpers.ts +23 -0
  95. package/src/notifications/config-commands.ts +50 -0
  96. package/src/notifications/config.ts +107 -0
  97. package/src/notifications/helpers.ts +135 -0
  98. package/src/notifications/html-format.ts +389 -0
  99. package/src/notifications/index.ts +663 -0
  100. package/src/notifications/rate-limit-pool.ts +179 -0
  101. package/src/notifications/telegram-cli.ts +194 -0
  102. package/src/notifications/telegram-daemon-cli.ts +74 -0
  103. package/src/notifications/telegram-daemon-control.ts +370 -0
  104. package/src/notifications/telegram-daemon.ts +1370 -0
  105. package/src/notifications/telegram-reference.ts +335 -0
  106. package/src/notifications/threaded-inbound.ts +80 -0
  107. package/src/notifications/threaded-render.ts +155 -0
  108. package/src/notifications/topic-registry.ts +133 -0
  109. package/src/rlm/index.ts +19 -0
  110. package/src/sdk.ts +16 -0
  111. package/src/session/agent-session.ts +113 -3
  112. package/src/session/auth-storage.ts +3 -0
  113. package/src/session/session-dump-format.ts +43 -2
  114. package/src/session/session-manager.ts +39 -5
  115. package/src/setup/credential-auto-import.ts +258 -0
  116. package/src/setup/credential-import.ts +17 -0
  117. package/src/setup/hermes/templates/operator-instructions.v1.md +10 -0
  118. package/src/setup/host-plugin-setup.ts +142 -0
  119. package/src/slash-commands/builtin-registry.ts +4 -1
  120. package/src/task/executor.ts +5 -1
  121. package/src/tools/ask-answer-registry.ts +25 -0
  122. package/src/tools/ask.ts +74 -4
  123. package/src/tools/image-gen.ts +5 -8
  124. package/src/tools/index.ts +19 -0
  125. package/src/tools/inspect-image.ts +16 -11
  126. package/src/tools/subagent-render.ts +7 -0
  127. package/src/tools/subagent.ts +38 -7
@@ -0,0 +1,663 @@
1
+ /**
2
+ * Notifications extension.
3
+ *
4
+ * Hosts a per-session loopback WebSocket notification server (the Rust core via
5
+ * N-API) and bridges GJC session events + the `ask` tool to it so a remote client
6
+ * (e.g. a Telegram bot) can both see action-needed signals and ANSWER them —
7
+ * without requiring RPC/unattended mode:
8
+ *
9
+ * - `ask` (interactive): registers an {@link AskAnswerSource}; the ask tool races
10
+ * the local UI against a remote reply. First valid answer wins; a local answer
11
+ * aborts the remote wait (and broadcasts `action_resolved` resolvedBy=local).
12
+ * - `ask` (unattended/RPC): observes emitted workflow gates and resolves the real
13
+ * gate on a remote reply via `ctx.workflowGate`.
14
+ * - `turn_end` -> `action_needed` (kind `idle`, deduped per turn).
15
+ * - `session_shutdown` -> stop the server + deregister the answer source.
16
+ *
17
+ * Enable with Settings notifications config, `GJC_NOTIFICATIONS=1` (a token is
18
+ * generated), or `GJC_NOTIFICATIONS_TOKEN`.
19
+ */
20
+
21
+ import { execFile } from "node:child_process";
22
+ import * as crypto from "node:crypto";
23
+ import * as fs from "node:fs";
24
+ import * as os from "node:os";
25
+ import * as path from "node:path";
26
+ import { promisify } from "node:util";
27
+ import { NotificationServer } from "@gajae-code/natives";
28
+ import { logger } from "@gajae-code/utils";
29
+ import { Settings } from "../config/settings";
30
+ import type { ExtensionCommandContext, ExtensionContext, ExtensionFactory } from "../extensibility/extensions";
31
+ import { registerAskAnswerSource } from "../tools/ask-answer-registry";
32
+ import {
33
+ getNotificationConfig,
34
+ isGloballyConfigured,
35
+ isSessionNotificationsEnabled,
36
+ type NotificationConfig,
37
+ sessionTag,
38
+ } from "./config";
39
+ import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage } from "./helpers";
40
+ import { ensureTelegramDaemonRunning } from "./telegram-daemon";
41
+
42
+ /** Resolve the git dir for `cwd`, handling worktrees where `.git` is a file. */
43
+ function gitDir(cwd: string): string | undefined {
44
+ const dot = path.join(cwd, ".git");
45
+ try {
46
+ if (fs.statSync(dot).isDirectory()) return dot;
47
+ const m = fs
48
+ .readFileSync(dot, "utf8")
49
+ .trim()
50
+ .match(/^gitdir:\s*(.+)$/);
51
+ if (m) return path.resolve(cwd, m[1]);
52
+ } catch {}
53
+ return undefined;
54
+ }
55
+
56
+ /** Best-effort current branch from `.git/HEAD` (no git spawn). */
57
+ function readGitBranch(cwd: string): string | undefined {
58
+ const gd = gitDir(cwd);
59
+ if (!gd) return undefined;
60
+ try {
61
+ const head = fs.readFileSync(path.join(gd, "HEAD"), "utf8").trim();
62
+ const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
63
+ return m ? m[1] : head.slice(0, 12);
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ /** Resolve the shared git dir (the main repo's `.git`) for a possibly-linked worktree. */
70
+ function gitCommonDir(gd: string): string {
71
+ try {
72
+ const raw = fs.readFileSync(path.join(gd, "commondir"), "utf8").trim();
73
+ if (raw) return path.resolve(gd, raw);
74
+ } catch {}
75
+ return gd;
76
+ }
77
+
78
+ /**
79
+ * Best-effort real repository name (no git spawn): resolves the main worktree
80
+ * root directory so linked worktrees report the repo (e.g. `gajae-code`)
81
+ * instead of the worktree directory (e.g. `feat-foo-01047f11`).
82
+ */
83
+ export function readGitRepoName(cwd: string): string | undefined {
84
+ const gd = gitDir(cwd);
85
+ if (!gd) return undefined;
86
+ const commonDir = gitCommonDir(gd);
87
+ // Strip the trailing `.git` to land on the main worktree root directory.
88
+ const repoRoot = path.basename(commonDir) === ".git" ? path.dirname(commonDir) : commonDir;
89
+ const name = path.basename(repoRoot);
90
+ return name && name !== ".git" ? name : undefined;
91
+ }
92
+
93
+ /** Build the one-time identity header fields for a session thread. */
94
+ function buildIdentity(
95
+ cwd: string,
96
+ sessionName?: string,
97
+ ): {
98
+ repo: string;
99
+ branch: string;
100
+ machine: string;
101
+ title?: string;
102
+ } {
103
+ const repo = readGitRepoName(cwd) ?? (path.basename(cwd) || cwd);
104
+ const branch = readGitBranch(cwd) ?? "(detached)";
105
+ // Send repo/branch and the raw session title separately; the consumer
106
+ // composes the topic name ("{repo}/{branch}" before the session title is
107
+ // auto-generated, then "{repo}/{branch} - {session title}" once it exists).
108
+ return { repo, branch, machine: os.hostname(), title: sessionName };
109
+ }
110
+
111
+ const execFileAsync = promisify(execFile);
112
+
113
+ /** Best-effort working-tree diff stat for the context update (no throw). */
114
+ async function readGitDiffStat(cwd: string): Promise<string | undefined> {
115
+ try {
116
+ const { stdout } = await execFileAsync("git", ["-C", cwd, "diff", "--stat", "--no-color"], {
117
+ timeout: 3000,
118
+ maxBuffer: 256 * 1024,
119
+ });
120
+ const trimmed = stdout.trim();
121
+ return trimmed ? trimmed.slice(0, 1500) : undefined;
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ }
126
+
127
+ interface PendingInteractiveAsk {
128
+ resolve: (label: string | undefined) => void;
129
+ options: string[];
130
+ }
131
+
132
+ interface SessionRuntime {
133
+ server: NotificationServer;
134
+ idleSeq: number;
135
+ /** Interactive asks awaiting a remote answer, by action id. */
136
+ pendingInteractive: Map<string, PendingInteractiveAsk>;
137
+ /** Deregisters this session's ask answer source. */
138
+ disposeAnswerSource: () => void;
139
+ redact: boolean;
140
+ sessionTag: string;
141
+ /** Whether the agent loop is currently running (drives the typing indicator). */
142
+ busy: boolean;
143
+ /** Inbound Telegram update ids injected but not yet consumed by a turn. */
144
+ pendingInbound: Set<number>;
145
+ }
146
+
147
+ interface ResolvedSettings {
148
+ settings: Settings | undefined;
149
+ cfg: NotificationConfig;
150
+ settingsAvailable: boolean;
151
+ }
152
+
153
+ const defaultConfig: NotificationConfig = {
154
+ enabled: false,
155
+ redact: false,
156
+ verbosity: "lean",
157
+ idleTimeoutMs: 60_000,
158
+ };
159
+
160
+ export function notificationsEnabled(): boolean {
161
+ return process.env.GJC_NOTIFICATIONS === "1" || Boolean(process.env.GJC_NOTIFICATIONS_TOKEN);
162
+ }
163
+
164
+ function resolveSettings(): ResolvedSettings {
165
+ try {
166
+ const settings = Settings.instance;
167
+ return { settings, cfg: getNotificationConfig(settings), settingsAvailable: true };
168
+ } catch {
169
+ return { settings: undefined, cfg: defaultConfig, settingsAvailable: false };
170
+ }
171
+ }
172
+
173
+ function resolveToken(): string {
174
+ return process.env.GJC_NOTIFICATIONS_TOKEN ?? crypto.randomBytes(24).toString("base64url");
175
+ }
176
+
177
+ function parseAnswer(answerJson: string): unknown {
178
+ try {
179
+ return JSON.parse(answerJson);
180
+ } catch {
181
+ return answerJson;
182
+ }
183
+ }
184
+
185
+ /** Map a client answer to the option LABEL the local UI would return (or free text). */
186
+ function mapAnswerToLabel(answerJson: string, options: string[]): string | undefined {
187
+ const answer = parseAnswer(answerJson);
188
+ if (typeof answer === "number") return options[answer];
189
+ if (typeof answer === "string") return answer;
190
+ if (answer && typeof answer === "object") {
191
+ const sel = (answer as { selected?: unknown; custom?: unknown }).selected;
192
+ if (Array.isArray(sel) && sel.length > 0) {
193
+ const first = sel[0];
194
+ return typeof first === "number" ? options[first] : String(first);
195
+ }
196
+ const custom = (answer as { custom?: unknown }).custom;
197
+ if (typeof custom === "string") return custom;
198
+ }
199
+ return undefined;
200
+ }
201
+
202
+ /** Map a client answer to the workflow-gate answer shape (unattended mode). */
203
+ function mapAnswerToGate(
204
+ answerJson: string,
205
+ options: string[],
206
+ ): { selected: string[]; other?: boolean; custom?: string } {
207
+ const answer = parseAnswer(answerJson);
208
+ if (typeof answer === "number") {
209
+ const label = options[answer];
210
+ return label === undefined ? { selected: [], other: true, custom: String(answer) } : { selected: [label] };
211
+ }
212
+ if (typeof answer === "string") {
213
+ return options.includes(answer) ? { selected: [answer] } : { selected: [], other: true, custom: answer };
214
+ }
215
+ if (answer && typeof answer === "object") {
216
+ const obj = answer as { selected?: unknown; custom?: unknown };
217
+ const selected = Array.isArray(obj.selected)
218
+ ? obj.selected.map(s => (typeof s === "number" ? (options[s] ?? String(s)) : String(s)))
219
+ : [];
220
+ const custom = typeof obj.custom === "string" ? obj.custom : undefined;
221
+ return { selected, other: custom !== undefined, custom };
222
+ }
223
+ return { selected: [] };
224
+ }
225
+
226
+ export const createNotificationsExtension: ExtensionFactory = api => {
227
+ const runtimes = new Map<string, SessionRuntime>();
228
+ const disabledSessions = new Set<string>();
229
+ const sessionId = (ctx: ExtensionContext): string => ctx.sessionManager.getSessionId();
230
+
231
+ function stopSession(id: string): boolean {
232
+ const rt = runtimes.get(id);
233
+ if (!rt) return false;
234
+ runtimes.delete(id);
235
+ try {
236
+ rt.disposeAnswerSource();
237
+ } catch {}
238
+ // Resolve any still-pending interactive asks so the ask tool is not left hanging.
239
+ for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
240
+ rt.pendingInteractive.clear();
241
+ try {
242
+ rt.server.stop();
243
+ } catch (e) {
244
+ logger.warn(`notifications: stop failed: ${String(e)}`);
245
+ }
246
+ return true;
247
+ }
248
+
249
+ function isEnabledForSession(id: string, cfg: NotificationConfig): boolean {
250
+ return isSessionNotificationsEnabled({ cfg, env: process.env, sessionDisabled: disabledSessions.has(id) });
251
+ }
252
+
253
+ async function startSession(ctx: ExtensionContext): Promise<"started" | "already" | "disabled" | "failed"> {
254
+ const id = sessionId(ctx);
255
+ const { settings, cfg, settingsAvailable } = resolveSettings();
256
+ if (!isEnabledForSession(id, cfg)) return "disabled";
257
+ if (runtimes.has(id)) return "already";
258
+
259
+ const stateRoot = path.join(ctx.cwd, ".gjc", "state");
260
+ const gate = ctx.workflowGate;
261
+ const unattended =
262
+ gate?.isUnattended?.() === true &&
263
+ typeof gate.onGateEmitted === "function" &&
264
+ typeof gate.resolveGate === "function";
265
+ const gateOptions = new Map<string, string[]>();
266
+ const pendingInteractive = new Map<string, PendingInteractiveAsk>();
267
+ const tag = sessionTag(id);
268
+ const redact = cfg.redact;
269
+
270
+ // The SDK can always answer now (interactive via the answer source, or the
271
+ // unattended gate), so the endpoint advertises a resolver.
272
+ const server = new NotificationServer(id, resolveToken(), stateRoot, true);
273
+
274
+ server.onReply((err, reply) => {
275
+ if (err || !reply) return;
276
+ // 1) Interactive ask awaiting a remote answer.
277
+ const pending = pendingInteractive.get(reply.id);
278
+ if (pending) {
279
+ pendingInteractive.delete(reply.id);
280
+ const label = mapAnswerToLabel(reply.answerJson, pending.options);
281
+ try {
282
+ server.resolveClient(reply.id, reply.answerJson, reply.idempotencyKey ?? undefined);
283
+ } catch (e) {
284
+ logger.warn(`notifications: resolveClient failed: ${String(e)}`);
285
+ }
286
+ pending.resolve(label);
287
+ return;
288
+ }
289
+ // 2) Unattended workflow gate: resolve the real gate, then confirm.
290
+ if (unattended && gate?.resolveGate) {
291
+ const answer = mapAnswerToGate(reply.answerJson, gateOptions.get(reply.id) ?? []);
292
+ gate
293
+ .resolveGate({ gate_id: reply.id, answer, idempotency_key: reply.idempotencyKey ?? undefined })
294
+ .then(() => server.resolveClient(reply.id, reply.answerJson, reply.idempotencyKey ?? undefined))
295
+ .catch(e => {
296
+ logger.warn(`notifications: resolveGate failed: ${String(e)}`);
297
+ try {
298
+ server.reject(reply.id, "invalid_answer");
299
+ } catch {}
300
+ });
301
+ return;
302
+ }
303
+ // 3) No matching pending ask.
304
+ try {
305
+ server.reject(reply.id, "unknown_action");
306
+ } catch (e) {
307
+ logger.warn(`notifications: reject failed: ${String(e)}`);
308
+ }
309
+ });
310
+
311
+ // Inbound free-text injection / in-thread config command from a session
312
+ // thread (forwarded by the daemon over the WS, fail-closed at the daemon).
313
+ server.onInbound((err, inbound) => {
314
+ if (err || !inbound) return;
315
+ if (inbound.kind === "user_message" && inbound.text) {
316
+ // Inject as a user turn (steers/continues the agent; the resulting
317
+ // turn streams back via the turn_end handler even when not idle).
318
+ // Record the update id so it can be acked as "consumed" on the next
319
+ // turn_start, and steer (vs start a fresh turn) when already busy.
320
+ const rt = runtimes.get(id);
321
+ if (rt && typeof inbound.updateId === "number") rt.pendingInbound.add(inbound.updateId);
322
+ try {
323
+ api.sendUserMessage(inbound.text, rt?.busy ? { deliverAs: "steer" } : undefined);
324
+ } catch (e) {
325
+ logger.warn(`notifications: sendUserMessage failed: ${String(e)}`);
326
+ }
327
+ return;
328
+ }
329
+ if (inbound.kind === "config_command") {
330
+ const rt = runtimes.get(id);
331
+ if (rt && typeof inbound.redact === "boolean") rt.redact = inbound.redact;
332
+ }
333
+ });
334
+
335
+ try {
336
+ const endpoint = await server.start();
337
+
338
+ // Interactive answer source: the ask tool races the local UI against this.
339
+ const disposeAnswerSource = registerAskAnswerSource(id, {
340
+ awaitAnswer(question, options, signal) {
341
+ if (signal?.aborted) return Promise.resolve(undefined);
342
+ const askId = `ask:${crypto.randomUUID()}`;
343
+ try {
344
+ server.registerAsk(
345
+ JSON.stringify(
346
+ notificationActionPayload(
347
+ { id: askId, kind: "ask", sessionId: id, question, options },
348
+ { redact, sessionTag: tag },
349
+ ),
350
+ ),
351
+ true,
352
+ );
353
+ } catch (e) {
354
+ logger.warn(`notifications: registerAsk failed: ${String(e)}`);
355
+ return Promise.resolve(undefined);
356
+ }
357
+ return new Promise<string | undefined>(resolve => {
358
+ pendingInteractive.set(askId, { resolve, options });
359
+ signal?.addEventListener("abort", () => {
360
+ if (!pendingInteractive.delete(askId)) return;
361
+ // Local UI answered: mark the remote action resolved-locally.
362
+ try {
363
+ server.resolveLocal(askId, undefined);
364
+ } catch {}
365
+ resolve(undefined);
366
+ });
367
+ });
368
+ },
369
+ });
370
+
371
+ runtimes.set(id, {
372
+ server,
373
+ idleSeq: 0,
374
+ pendingInteractive,
375
+ disposeAnswerSource,
376
+ redact,
377
+ sessionTag: tag,
378
+ busy: false,
379
+ pendingInbound: new Set<number>(),
380
+ });
381
+ logger.info(`notifications: serving session ${id} at ${endpoint.url} (unattended=${unattended})`);
382
+
383
+ if (settingsAvailable && settings && isGloballyConfigured(cfg)) {
384
+ try {
385
+ await ensureTelegramDaemonRunning({ settings, cwd: ctx.cwd, sessionId: id });
386
+ } catch (e) {
387
+ logger.warn(`notifications: failed to ensure Telegram daemon: ${String(e)}`);
388
+ }
389
+ }
390
+
391
+ // One-time identity header (repo/branch/machine/session) pinned at the top
392
+ // of the session thread by the daemon.
393
+ try {
394
+ server.pushFrame(
395
+ JSON.stringify({
396
+ type: "identity_header",
397
+ sessionId: id,
398
+ ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
399
+ }),
400
+ );
401
+ } catch (e) {
402
+ logger.warn(`notifications: identity_header failed: ${String(e)}`);
403
+ }
404
+
405
+ // Unattended: a real ask emits a workflow gate; register it repliable by gate_id.
406
+ if (unattended && gate?.onGateEmitted) {
407
+ gate.onGateEmitted(g => {
408
+ const options = (g.options ?? []).map(o => String((o as { label?: unknown }).label ?? ""));
409
+ gateOptions.set(g.gate_id, options);
410
+ const promptCtx = g.context as { prompt?: unknown; title?: unknown } | undefined;
411
+ const question =
412
+ (typeof promptCtx?.prompt === "string" && promptCtx.prompt) ||
413
+ (typeof promptCtx?.title === "string" && promptCtx.title) ||
414
+ "Question";
415
+ try {
416
+ server.registerAsk(
417
+ JSON.stringify(
418
+ notificationActionPayload(
419
+ { id: g.gate_id, kind: "ask", sessionId: id, question, options },
420
+ { redact, sessionTag: tag },
421
+ ),
422
+ ),
423
+ true,
424
+ );
425
+ } catch (e) {
426
+ logger.warn(`notifications: registerAsk (gate) failed: ${String(e)}`);
427
+ }
428
+ });
429
+ }
430
+ return "started";
431
+ } catch (e) {
432
+ logger.warn(`notifications: failed to start server: ${String(e)}`);
433
+ return "failed";
434
+ }
435
+ }
436
+
437
+ api.registerCommand("notify", {
438
+ description: "Control notifications for this session (on, off, status).",
439
+ async handler(args: string, ctx: ExtensionCommandContext): Promise<void> {
440
+ const id = sessionId(ctx);
441
+ const command = args.trim().split(/\s+/, 1)[0]?.toLowerCase() || "status";
442
+ const resolved = resolveSettings();
443
+ const enabledWithoutLocalOff = isSessionNotificationsEnabled({
444
+ cfg: resolved.cfg,
445
+ env: process.env,
446
+ sessionDisabled: false,
447
+ });
448
+
449
+ if (command === "off") {
450
+ disabledSessions.add(id);
451
+ const stopped = stopSession(id);
452
+ ctx.ui.notify(
453
+ stopped
454
+ ? "Notifications disabled for this session."
455
+ : "Notifications already disabled for this session.",
456
+ "info",
457
+ );
458
+ return;
459
+ }
460
+
461
+ if (command === "on") {
462
+ if (process.env.GJC_NOTIFICATIONS === "0") {
463
+ ctx.ui.notify(
464
+ "Notifications remain disabled: GJC_NOTIFICATIONS=0 is an authoritative opt-out.",
465
+ "warning",
466
+ );
467
+ return;
468
+ }
469
+ if (!enabledWithoutLocalOff) {
470
+ ctx.ui.notify(
471
+ "Notifications are not configured. Run `gjc notify setup` or set GJC_NOTIFICATIONS=1.",
472
+ "warning",
473
+ );
474
+ return;
475
+ }
476
+ disabledSessions.delete(id);
477
+ const result = await startSession(ctx);
478
+ ctx.ui.notify(
479
+ result === "started"
480
+ ? "Notifications enabled for this session."
481
+ : result === "already"
482
+ ? "Notifications already enabled for this session."
483
+ : result === "failed"
484
+ ? "Notifications failed to start for this session."
485
+ : "Notifications are not configured. Run `gjc notify setup` or set GJC_NOTIFICATIONS=1.",
486
+ result === "failed" ? "error" : result === "disabled" ? "warning" : "info",
487
+ );
488
+ return;
489
+ }
490
+
491
+ if (command !== "status") {
492
+ ctx.ui.notify("Usage: /notify status | /notify on | /notify off", "warning");
493
+ return;
494
+ }
495
+
496
+ const running = runtimes.has(id);
497
+ const locallyDisabled = disabledSessions.has(id);
498
+ const enabled = isEnabledForSession(id, resolved.cfg);
499
+ ctx.ui.notify(
500
+ `Notifications ${running ? "running" : enabled ? "enabled" : "disabled"} for this session; redaction ${resolved.cfg.redact ? "on" : "off"}${locallyDisabled ? "; locally off" : ""}.`,
501
+ "info",
502
+ );
503
+ },
504
+ });
505
+
506
+ api.on("session_start", async (_event, ctx) => {
507
+ await startSession(ctx);
508
+ });
509
+
510
+ // Drive the live typing indicator: mark busy when the agent loop starts so
511
+ // the daemon shows "typing…" in the thread while the agent is thinking,
512
+ // before any turn output exists. Cleared on `agent_end` below.
513
+ api.on("agent_start", (_event, ctx) => {
514
+ const id = sessionId(ctx);
515
+ const rt = runtimes.get(id);
516
+ if (!rt) return;
517
+ rt.busy = true;
518
+ try {
519
+ rt.server.pushFrame(JSON.stringify({ type: "activity", sessionId: id, state: "busy" }));
520
+ } catch (e) {
521
+ logger.warn(`notifications: activity (busy) failed: ${String(e)}`);
522
+ }
523
+ });
524
+
525
+ // Each turn that starts has absorbed any messages injected from the thread,
526
+ // so ack them as "consumed": the daemon flips the queued reaction on the
527
+ // originating Telegram message to the consumed (double-check) reaction.
528
+ api.on("turn_start", (_event, ctx) => {
529
+ const id = sessionId(ctx);
530
+ const rt = runtimes.get(id);
531
+ if (!rt || rt.pendingInbound.size === 0) return;
532
+ for (const updateId of rt.pendingInbound) {
533
+ try {
534
+ rt.server.pushFrame(JSON.stringify({ type: "inbound_ack", sessionId: id, updateId, state: "consumed" }));
535
+ } catch (e) {
536
+ logger.warn(`notifications: inbound_ack failed: ${String(e)}`);
537
+ }
538
+ }
539
+ rt.pendingInbound.clear();
540
+ });
541
+
542
+ // Idle fires on `agent_end` (the agent loop settling to await the user), NOT
543
+ // per `turn_end`. turn_end fires once per turn iteration, so a single
544
+ // user-visible idle previously produced many idle pings (the flood); agent_end
545
+ // fires exactly once per settle, yielding exactly one idle notification.
546
+ api.on("agent_end", (_event, ctx) => {
547
+ const id = sessionId(ctx);
548
+ const rt = runtimes.get(id);
549
+ if (!rt) return;
550
+ const seq = rt.idleSeq++;
551
+ // Clear the typing indicator: the agent loop has settled.
552
+ rt.busy = false;
553
+ try {
554
+ rt.server.pushFrame(JSON.stringify({ type: "activity", sessionId: id, state: "idle" }));
555
+ } catch (e) {
556
+ logger.warn(`notifications: activity (idle) failed: ${String(e)}`);
557
+ }
558
+ // Re-assert the identity header so the daemon renames the topic once the
559
+ // session title has been auto-generated ("{repo}/{branch} - {title}"). The
560
+ // daemon only renames when the title actually changed.
561
+ try {
562
+ rt.server.pushFrame(
563
+ JSON.stringify({
564
+ type: "identity_header",
565
+ sessionId: id,
566
+ ...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
567
+ }),
568
+ );
569
+ } catch {}
570
+ try {
571
+ rt.server.noteIdle(
572
+ JSON.stringify(
573
+ notificationActionPayload(
574
+ {
575
+ id: `idle:${id}#${seq}`,
576
+ kind: "idle",
577
+ sessionId: id,
578
+ summary: undefined,
579
+ },
580
+ { redact: rt.redact, sessionTag: rt.sessionTag },
581
+ ),
582
+ ),
583
+ );
584
+ } catch (e) {
585
+ logger.warn(`notifications: noteIdle failed: ${String(e)}`);
586
+ }
587
+
588
+ // On idle, stream a context update with metadata (token/model usage +
589
+ // working-tree diff) unless redaction is on. The agent's last message is
590
+ // NOT repeated here — it is already streamed once via `turn_stream`.
591
+ if (!rt.redact) {
592
+ const usage = (
593
+ ctx as { getContextUsage?: () => { tokens: number | null; contextWindow: number } | undefined }
594
+ ).getContextUsage?.();
595
+ const model = (ctx as { getModel?: () => { id?: string } | undefined }).getModel?.();
596
+ const tokenUsage = usage && usage.tokens != null ? `${usage.tokens}/${usage.contextWindow}` : undefined;
597
+ const modelId = model?.id;
598
+ void readGitDiffStat(ctx.cwd).then(diff => {
599
+ if (!diff && !tokenUsage && !modelId) return;
600
+ try {
601
+ rt.server.pushFrame(
602
+ JSON.stringify({
603
+ type: "context_update",
604
+ sessionId: id,
605
+ tokenUsage,
606
+ model: modelId,
607
+ diff,
608
+ }),
609
+ );
610
+ } catch (e) {
611
+ logger.warn(`notifications: context_update failed: ${String(e)}`);
612
+ }
613
+ });
614
+ }
615
+ });
616
+
617
+ // Stream viable agent output per turn (the live thread mirror). Unlike idle,
618
+ // turn output is expected to be multiple messages — one per turn that
619
+ // produced assistant text. Tool-only turns yield no text and are skipped.
620
+ // Redaction suppresses streamed content (only the one-time identity header
621
+ // survives redaction). The daemon coalesces/throttles these via its shared
622
+ // rate-limit pool before sending to Telegram.
623
+ api.on("turn_end", (event, ctx) => {
624
+ const id = sessionId(ctx);
625
+ const rt = runtimes.get(id);
626
+ if (!rt) return;
627
+ if (rt.redact) return;
628
+ const text = summaryFromMessage(event.message, 3500);
629
+ if (!text) return;
630
+ try {
631
+ rt.server.pushFrame(JSON.stringify({ type: "turn_stream", sessionId: id, phase: "finalized", text }));
632
+ } catch (e) {
633
+ logger.warn(`notifications: pushFrame (turn) failed: ${String(e)}`);
634
+ }
635
+ });
636
+
637
+ // Stream agent-produced images (computer/browser/tool screenshots) as
638
+ // image_attachment frames; suppressed when redaction is on.
639
+ api.on("message_end", (event, ctx) => {
640
+ const id = sessionId(ctx);
641
+ const rt = runtimes.get(id);
642
+ if (!rt || rt.redact) return;
643
+ for (const img of imageAttachmentsFromMessage(event.message)) {
644
+ try {
645
+ rt.server.pushFrame(
646
+ JSON.stringify({
647
+ type: "image_attachment",
648
+ sessionId: id,
649
+ source: img.source,
650
+ mime: img.mime,
651
+ data: img.data,
652
+ }),
653
+ );
654
+ } catch (e) {
655
+ logger.warn(`notifications: image_attachment failed: ${String(e)}`);
656
+ }
657
+ }
658
+ });
659
+
660
+ api.on("session_shutdown", (_event, ctx) => {
661
+ stopSession(sessionId(ctx));
662
+ });
663
+ };