@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.
- package/CHANGELOG.md +57 -0
- package/dist/types/cli/mcp-cli.d.ts +25 -0
- package/dist/types/cli/notify-cli.d.ts +2 -0
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/commands/mcp.d.ts +70 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/settings-schema.d.ts +39 -2
- package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +1 -1
- package/dist/types/lsp/types.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
- package/dist/types/modes/theme/defaults/index.d.ts +99 -0
- package/dist/types/notifications/attachment-registry.d.ts +17 -0
- package/dist/types/notifications/chat-adapters.d.ts +9 -0
- package/dist/types/notifications/config.d.ts +9 -1
- package/dist/types/notifications/engine.d.ts +59 -0
- package/dist/types/notifications/managed-daemon.d.ts +48 -0
- package/dist/types/notifications/operator-runtime.d.ts +52 -0
- package/dist/types/notifications/telegram-daemon.d.ts +73 -16
- package/dist/types/notifications/threaded-inbound.d.ts +19 -0
- package/dist/types/notifications/threaded-render.d.ts +6 -1
- package/dist/types/notifications/topic-registry.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/tools/composer-bash-policy.d.ts +14 -0
- package/dist/types/tools/fetch.d.ts +23 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/telegram-send.d.ts +32 -0
- package/dist/types/web/insane/bridge.d.ts +103 -0
- package/dist/types/web/insane/url-guard.d.ts +25 -0
- package/dist/types/web/scrapers/types.d.ts +5 -0
- package/dist/types/web/scrapers/utils.d.ts +7 -1
- package/dist/types/web/search/provider.d.ts +18 -1
- package/dist/types/web/search/providers/insane.d.ts +53 -0
- package/dist/types/web/search/providers/text-citations.d.ts +23 -0
- package/dist/types/web/search/types.d.ts +12 -4
- package/package.json +10 -8
- package/scripts/verify-insane-vendor.ts +132 -0
- package/src/cli/args.ts +1 -1
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/mcp-cli.ts +272 -0
- package/src/cli/notify-cli.ts +152 -5
- package/src/cli.ts +6 -2
- package/src/commands/mcp.ts +117 -0
- package/src/commands/team.ts +1 -1
- package/src/config/keybindings.ts +2 -2
- package/src/config/settings-schema.ts +30 -1
- package/src/deep-interview/plaintext-gate-guard.ts +94 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +4 -3
- package/src/defaults/gjc/skills/ralplan/SKILL.md +11 -4
- package/src/defaults/gjc/skills/team/SKILL.md +3 -2
- package/src/extensibility/extensions/runner.ts +1 -0
- package/src/extensibility/shared-events.ts +1 -0
- package/src/gjc-runtime/launch-tmux.ts +17 -3
- package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
- package/src/gjc-runtime/ralplan-runtime.ts +2 -2
- package/src/gjc-runtime/tmux-common.ts +3 -1
- package/src/gjc-runtime/ultragoal-guard.ts +25 -8
- package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
- package/src/gjc-runtime/workflow-manifest.ts +7 -2
- package/src/hooks/skill-state.ts +57 -0
- package/src/internal-urls/docs-index.generated.ts +14 -11
- package/src/lsp/config.ts +16 -3
- package/src/lsp/defaults.json +7 -0
- package/src/lsp/types.ts +2 -0
- package/src/modes/bridge/bridge-mode.ts +11 -0
- package/src/modes/components/custom-editor.ts +2 -0
- package/src/modes/components/footer.ts +2 -3
- package/src/modes/components/model-selector.ts +12 -0
- package/src/modes/components/status-line/git-utils.ts +25 -0
- package/src/modes/components/status-line.ts +10 -11
- package/src/modes/components/welcome.ts +2 -3
- package/src/modes/controllers/event-controller.ts +15 -0
- package/src/modes/controllers/selector-controller.ts +3 -0
- package/src/modes/interactive-mode.ts +48 -3
- package/src/modes/shared/agent-wire/scopes.ts +1 -1
- package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
- package/src/modes/theme/defaults/index.ts +2 -0
- package/src/modes/utils/context-usage.ts +2 -2
- package/src/notifications/attachment-registry.ts +23 -0
- package/src/notifications/chat-adapters.ts +147 -0
- package/src/notifications/config.ts +23 -2
- package/src/notifications/engine.ts +100 -0
- package/src/notifications/index.ts +180 -38
- package/src/notifications/managed-daemon.ts +163 -0
- package/src/notifications/operator-runtime.ts +171 -0
- package/src/notifications/telegram-daemon.ts +553 -236
- package/src/notifications/threaded-inbound.ts +60 -4
- package/src/notifications/threaded-render.ts +20 -2
- package/src/notifications/topic-registry.ts +5 -0
- package/src/session/agent-session.ts +82 -51
- package/src/slash-commands/helpers/parse.ts +2 -1
- package/src/tools/bash.ts +9 -0
- package/src/tools/composer-bash-policy.ts +96 -0
- package/src/tools/fetch.ts +94 -1
- package/src/tools/index.ts +3 -0
- package/src/tools/telegram-send.ts +137 -0
- package/src/web/insane/bridge.ts +350 -0
- package/src/web/insane/url-guard.ts +159 -0
- package/src/web/scrapers/types.ts +143 -45
- package/src/web/scrapers/utils.ts +70 -19
- package/src/web/search/provider.ts +77 -18
- package/src/web/search/providers/anthropic.ts +70 -3
- package/src/web/search/providers/codex.ts +1 -119
- package/src/web/search/providers/gemini.ts +99 -0
- package/src/web/search/providers/insane.ts +551 -0
- package/src/web/search/providers/openai-compatible.ts +66 -32
- package/src/web/search/providers/text-citations.ts +111 -0
- package/src/web/search/types.ts +13 -2
- package/vendor/insane-search/LICENSE +21 -0
- package/vendor/insane-search/MANIFEST.json +24 -0
- package/vendor/insane-search/engine/__init__.py +23 -0
- package/vendor/insane-search/engine/__main__.py +128 -0
- package/vendor/insane-search/engine/bias_check.py +183 -0
- package/vendor/insane-search/engine/executor.py +254 -0
- package/vendor/insane-search/engine/fetch_chain.py +725 -0
- package/vendor/insane-search/engine/learning.py +175 -0
- package/vendor/insane-search/engine/phase0.py +214 -0
- package/vendor/insane-search/engine/safety.py +91 -0
- package/vendor/insane-search/engine/templates/package.json +11 -0
- package/vendor/insane-search/engine/templates/playwright_mobile_chrome.js +188 -0
- package/vendor/insane-search/engine/templates/playwright_real_chrome.js +243 -0
- package/vendor/insane-search/engine/tests/test_hardening.py +57 -0
- package/vendor/insane-search/engine/tests/test_smoke.py +152 -0
- package/vendor/insane-search/engine/tests/test_u1.py +200 -0
- package/vendor/insane-search/engine/tests/test_u4.py +131 -0
- package/vendor/insane-search/engine/tests/test_u5.py +163 -0
- package/vendor/insane-search/engine/tests/test_u7.py +124 -0
- package/vendor/insane-search/engine/transport.py +211 -0
- package/vendor/insane-search/engine/url_transforms.py +98 -0
- package/vendor/insane-search/engine/validators.py +331 -0
- package/vendor/insane-search/engine/waf_detector.py +214 -0
- 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 +
|
|
41
|
+
/** Is global config sufficient for auto-on (enabled + at least one configured adapter)? */
|
|
26
42
|
export function isGloballyConfigured(cfg: NotificationConfig): boolean {
|
|
27
|
-
return
|
|
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"
|
|
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
|
|
326
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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.
|