@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.
- package/CHANGELOG.md +29 -0
- package/dist/types/async/job-manager.d.ts +3 -1
- package/dist/types/cli/daemon-cli.d.ts +25 -0
- package/dist/types/cli/notify-cli.d.ts +23 -0
- package/dist/types/cli/setup-cli.d.ts +20 -1
- package/dist/types/commands/daemon.d.ts +41 -0
- package/dist/types/commands/notify.d.ts +41 -0
- package/dist/types/config/model-profile-activation.d.ts +12 -0
- package/dist/types/config/model-profiles.d.ts +2 -1
- package/dist/types/config/model-registry.d.ts +3 -3
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +38 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/daemon/builtin.d.ts +20 -0
- package/dist/types/daemon/control-types.d.ts +57 -0
- package/dist/types/daemon/runtime.d.ts +25 -0
- package/dist/types/extensibility/extensions/types.d.ts +8 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +2 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +15 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +14 -0
- package/dist/types/modes/components/oauth-selector.d.ts +2 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -2
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
- package/dist/types/modes/types.d.ts +7 -1
- package/dist/types/notifications/config-commands.d.ts +26 -0
- package/dist/types/notifications/config.d.ts +61 -0
- package/dist/types/notifications/helpers.d.ts +55 -0
- package/dist/types/notifications/html-format.d.ts +62 -0
- package/dist/types/notifications/index.d.ts +28 -0
- package/dist/types/notifications/rate-limit-pool.d.ts +93 -0
- package/dist/types/notifications/telegram-cli.d.ts +19 -0
- package/dist/types/notifications/telegram-daemon-cli.d.ts +11 -0
- package/dist/types/notifications/telegram-daemon-control.d.ts +56 -0
- package/dist/types/notifications/telegram-daemon.d.ts +276 -0
- package/dist/types/notifications/telegram-reference.d.ts +111 -0
- package/dist/types/notifications/threaded-inbound.d.ts +58 -0
- package/dist/types/notifications/threaded-render.d.ts +66 -0
- package/dist/types/notifications/topic-registry.d.ts +67 -0
- package/dist/types/rlm/index.d.ts +12 -0
- package/dist/types/session/agent-session.d.ts +39 -2
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/setup/credential-auto-import.d.ts +63 -0
- package/dist/types/setup/credential-import.d.ts +3 -0
- package/dist/types/setup/host-plugin-setup.d.ts +39 -0
- package/dist/types/tools/ask-answer-registry.d.ts +13 -0
- package/dist/types/tools/index.d.ts +18 -0
- package/dist/types/tools/subagent.d.ts +3 -0
- package/package.json +7 -7
- package/scripts/build-binary.ts +3 -0
- package/src/async/job-manager.ts +5 -1
- package/src/cli/daemon-cli.ts +122 -0
- package/src/cli/notify-cli.ts +274 -0
- package/src/cli/setup-cli.ts +173 -84
- package/src/cli.ts +2 -0
- package/src/commands/daemon.ts +47 -0
- package/src/commands/notify.ts +61 -0
- package/src/commands/setup.ts +11 -1
- package/src/config/model-profile-activation.ts +74 -5
- package/src/config/model-profiles.ts +7 -4
- package/src/config/model-registry.ts +6 -3
- package/src/config/models-config-schema.ts +1 -1
- package/src/config/settings-schema.ts +29 -0
- package/src/coordinator/contract.ts +3 -0
- package/src/coordinator-mcp/server.ts +270 -1
- package/src/daemon/builtin.ts +46 -0
- package/src/daemon/control-types.ts +65 -0
- package/src/daemon/runtime.ts +51 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
- package/src/extensibility/extensions/runner.ts +4 -0
- package/src/extensibility/extensions/types.ts +8 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +12 -4
- package/src/gjc-runtime/state-runtime.ts +18 -4
- package/src/gjc-runtime/state-writer.ts +8 -8
- package/src/gjc-runtime/ultragoal-guard.ts +57 -2
- package/src/gjc-runtime/ultragoal-runtime.ts +105 -19
- package/src/gjc-runtime/workflow-manifest.generated.json +27 -2
- package/src/gjc-runtime/workflow-manifest.ts +11 -1
- package/src/goals/tools/goal-tool.ts +11 -2
- package/src/internal-urls/docs-index.generated.ts +6 -5
- package/src/main.ts +30 -0
- package/src/modes/acp/acp-event-mapper.ts +1 -0
- package/src/modes/components/hook-editor.ts +7 -2
- package/src/modes/components/oauth-selector.ts +19 -0
- package/src/modes/controllers/event-controller.ts +20 -0
- package/src/modes/controllers/selector-controller.ts +80 -17
- package/src/modes/interactive-mode.ts +6 -2
- package/src/modes/runtime-init.ts +1 -0
- package/src/modes/shared/agent-wire/event-contract.ts +1 -0
- package/src/modes/shared/agent-wire/event-envelope.ts +1 -0
- package/src/modes/shared/agent-wire/event-observation.ts +16 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +22 -0
- package/src/modes/types.ts +7 -1
- package/src/modes/utils/ui-helpers.ts +23 -0
- package/src/notifications/config-commands.ts +50 -0
- package/src/notifications/config.ts +107 -0
- package/src/notifications/helpers.ts +135 -0
- package/src/notifications/html-format.ts +389 -0
- package/src/notifications/index.ts +663 -0
- package/src/notifications/rate-limit-pool.ts +179 -0
- package/src/notifications/telegram-cli.ts +194 -0
- package/src/notifications/telegram-daemon-cli.ts +74 -0
- package/src/notifications/telegram-daemon-control.ts +370 -0
- package/src/notifications/telegram-daemon.ts +1370 -0
- package/src/notifications/telegram-reference.ts +335 -0
- package/src/notifications/threaded-inbound.ts +80 -0
- package/src/notifications/threaded-render.ts +155 -0
- package/src/notifications/topic-registry.ts +133 -0
- package/src/rlm/index.ts +19 -0
- package/src/sdk.ts +16 -0
- package/src/session/agent-session.ts +113 -3
- package/src/session/auth-storage.ts +3 -0
- package/src/session/session-dump-format.ts +43 -2
- package/src/session/session-manager.ts +39 -5
- package/src/setup/credential-auto-import.ts +258 -0
- package/src/setup/credential-import.ts +17 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +10 -0
- package/src/setup/host-plugin-setup.ts +142 -0
- package/src/slash-commands/builtin-registry.ts +4 -1
- package/src/task/executor.ts +5 -1
- package/src/tools/ask-answer-registry.ts +25 -0
- package/src/tools/ask.ts +74 -4
- package/src/tools/image-gen.ts +5 -8
- package/src/tools/index.ts +19 -0
- package/src/tools/inspect-image.ts +16 -11
- package/src/tools/subagent-render.ts +7 -0
- 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
|
+
};
|