@gajae-code/coding-agent 0.11.8 → 0.11.10
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 +35 -1
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/config/model-profiles.d.ts +2 -3
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
- package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
- package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
- package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
- package/dist/types/gjc-runtime/state-writer.d.ts +45 -0
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
- package/dist/types/modes/components/welcome.d.ts +2 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
- package/dist/types/runtime-mcp/redaction.d.ts +2 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -1
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +8 -1
- package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +14 -7
- package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
- package/dist/types/sdk/session.d.ts +7 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +13 -3
- package/dist/types/session/internal/managed-session-storage.d.ts +8 -3
- package/dist/types/session/session-manager.d.ts +5 -1
- package/dist/types/session/session-storage.d.ts +13 -0
- package/dist/types/skill-state/active-state.d.ts +4 -0
- package/dist/types/task/provider-retry-status.d.ts +13 -0
- package/dist/types/task/types.d.ts +3 -0
- package/dist/types/tools/index.d.ts +3 -1
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +1 -0
- package/src/cli/mcp-cli.ts +4 -21
- package/src/cli/notify-cli.ts +1 -0
- package/src/cli.ts +32 -0
- package/src/commands/deep-interview.ts +9 -4
- package/src/commit/agentic/index.ts +3 -3
- package/src/commit/map-reduce/index.ts +2 -2
- package/src/config/model-profiles.ts +9 -10
- package/src/config/model-registry.ts +4 -2
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +78 -163
- package/src/eval/js/tool-bridge.ts +1 -1
- package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
- package/src/gjc-runtime/deep-interview-draft.ts +843 -0
- package/src/gjc-runtime/deep-interview-payload.ts +262 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +310 -227
- package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
- package/src/gjc-runtime/deep-interview-state.ts +916 -4
- package/src/gjc-runtime/ralplan-runtime.ts +3 -1
- package/src/gjc-runtime/state-writer.ts +166 -2
- package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
- package/src/gjc-runtime/workflow-manifest.ts +191 -5
- package/src/hooks/skill-state.ts +23 -1
- package/src/internal-urls/docs-index.generated.ts +6 -5
- package/src/modes/components/welcome.ts +10 -2
- package/src/modes/controllers/selector-controller.ts +38 -8
- package/src/modes/interactive-mode.ts +29 -9
- package/src/prompts/agent-fragments/restricted-bash.md +1 -1
- package/src/prompts/agents/architect.md +8 -1
- package/src/prompts/agents/critic.md +8 -1
- package/src/prompts/agents/planner.md +8 -1
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/runtime-mcp/json-rpc.ts +7 -4
- package/src/runtime-mcp/redaction.ts +30 -0
- package/src/sdk/broker/identity.ts +22 -12
- package/src/sdk/broker/session-index.ts +11 -1
- package/src/sdk/bus/chat-daemon-control.ts +5 -3
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/index.ts +60 -5
- package/src/sdk/bus/notification-service.ts +19 -5
- package/src/sdk/bus/operator-runtime.ts +31 -5
- package/src/sdk/bus/telegram-cli.ts +6 -13
- package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +15 -7
- package/src/sdk/bus/telegram-daemon-control.ts +14 -5
- package/src/sdk/bus/telegram-daemon.ts +685 -120
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +12 -0
- package/src/session/agent-session.ts +140 -10
- package/src/session/internal/managed-session-scope.ts +339 -228
- package/src/session/internal/managed-session-storage.ts +17 -4
- package/src/session/session-manager.ts +96 -33
- package/src/session/session-storage.ts +23 -0
- package/src/skill-state/active-state.ts +10 -0
- package/src/skill-state/workflow-mutation-guard.ts +41 -7
- package/src/task/executor.ts +154 -44
- package/src/task/provider-retry-status.ts +88 -0
- package/src/task/render.ts +45 -26
- package/src/task/types.ts +3 -0
- package/src/tools/ask.ts +1 -0
- package/src/tools/bash-allowed-prefixes.ts +45 -1
- package/src/tools/index.ts +3 -1
- package/src/tools/subagent-render.ts +74 -16
- package/src/tools/subagent.ts +2 -0
- package/vendor/insane-search/MANIFEST.json +4 -1
- package/vendor/insane-search/engine/bias_check.py +5 -0
- package/vendor/insane-search/engine/fetch_chain.py +67 -1
- package/vendor/insane-search/engine/transport.py +13 -2
- package/vendor/insane-search/engine/validators.py +4 -1
|
@@ -665,8 +665,16 @@ function gradientLogo(lines: readonly string[], phase = 0, shine?: ShineConfig):
|
|
|
665
665
|
|
|
666
666
|
/** Total length of the intro animation. */
|
|
667
667
|
const INTRO_MS = 3000;
|
|
668
|
-
/**
|
|
669
|
-
|
|
668
|
+
/** Resolve the intro cadence without making tests mutate global process state. */
|
|
669
|
+
export function resolveWelcomeIntroTickMs(
|
|
670
|
+
platform: NodeJS.Platform = process.platform,
|
|
671
|
+
tmux = process.env.TMUX,
|
|
672
|
+
): number {
|
|
673
|
+
return platform === "win32" && tmux ? 100 : 33;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Render at 30fps directly, but cap native Windows multiplexers at 10fps to avoid ConPTY output backpressure. */
|
|
677
|
+
const INTRO_TICK_MS = resolveWelcomeIntroTickMs();
|
|
670
678
|
/** Number of full gradient rotations the sweep performs before settling. */
|
|
671
679
|
const INTRO_SWEEPS = 2.5;
|
|
672
680
|
/** Number of times the shine highlight crosses the diagonal across the intro. */
|
|
@@ -64,8 +64,10 @@ import {
|
|
|
64
64
|
import type { NotificationSessionStatus } from "../../sdk/bus/session-control";
|
|
65
65
|
import {
|
|
66
66
|
ensureTelegramDaemonRunningDetailed,
|
|
67
|
+
readNotificationRootRegistration,
|
|
67
68
|
resolveTelegramSetupPreflight,
|
|
68
69
|
unregisterNotificationRoot,
|
|
70
|
+
withNotificationRootRegistryFence,
|
|
69
71
|
} from "../../sdk/bus/telegram-daemon";
|
|
70
72
|
import { TelegramDaemonController } from "../../sdk/bus/telegram-daemon-control";
|
|
71
73
|
import { runTelegramSetup, type TelegramSetupPreflight } from "../../sdk/bus/telegram-setup";
|
|
@@ -216,6 +218,12 @@ export interface NotificationsEditorAdapterContext {
|
|
|
216
218
|
notifyConfigChanged?: () => Promise<void> | void;
|
|
217
219
|
}
|
|
218
220
|
|
|
221
|
+
type TelegramDaemonStopResult = {
|
|
222
|
+
ok: boolean;
|
|
223
|
+
message: string;
|
|
224
|
+
before?: { health?: string };
|
|
225
|
+
};
|
|
226
|
+
|
|
219
227
|
export interface NotificationsEditorOperationDependencies {
|
|
220
228
|
getNotificationConfig: typeof getNotificationConfig;
|
|
221
229
|
maskToken: typeof maskToken;
|
|
@@ -231,14 +239,17 @@ export interface NotificationsEditorOperationDependencies {
|
|
|
231
239
|
reconcileCommittedTelegramConfiguration: typeof reconcileCommittedTelegramConfiguration;
|
|
232
240
|
saveTelegramInactive: typeof saveTelegramInactive;
|
|
233
241
|
removeTelegramConfiguration: typeof removeTelegramConfiguration;
|
|
242
|
+
readNotificationRootRegistration: typeof readNotificationRootRegistration;
|
|
234
243
|
unregisterNotificationRoot: typeof unregisterNotificationRoot;
|
|
244
|
+
stopTelegramDaemonIfRootRegistryFenceMatches(input: {
|
|
245
|
+
settings: Settings;
|
|
246
|
+
registryFingerprint: string;
|
|
247
|
+
stop: () => Promise<TelegramDaemonStopResult>;
|
|
248
|
+
}): Promise<boolean>;
|
|
249
|
+
|
|
235
250
|
reloadTelegramDaemon(settings: Settings): Promise<{ ok: boolean; message: string }>;
|
|
236
251
|
restartTelegramDaemon(settings: Settings): Promise<{ ok: boolean; message: string }>;
|
|
237
|
-
stopTelegramDaemon(settings: Settings): Promise<
|
|
238
|
-
ok: boolean;
|
|
239
|
-
message: string;
|
|
240
|
-
before?: { health?: string };
|
|
241
|
-
}>;
|
|
252
|
+
stopTelegramDaemon(settings: Settings): Promise<TelegramDaemonStopResult>;
|
|
242
253
|
}
|
|
243
254
|
|
|
244
255
|
const notificationEditorOperationDependencies: NotificationsEditorOperationDependencies = {
|
|
@@ -256,7 +267,18 @@ const notificationEditorOperationDependencies: NotificationsEditorOperationDepen
|
|
|
256
267
|
reconcileCommittedTelegramConfiguration,
|
|
257
268
|
saveTelegramInactive,
|
|
258
269
|
removeTelegramConfiguration,
|
|
270
|
+
readNotificationRootRegistration,
|
|
259
271
|
unregisterNotificationRoot,
|
|
272
|
+
stopTelegramDaemonIfRootRegistryFenceMatches: async input =>
|
|
273
|
+
await withNotificationRootRegistryFence({
|
|
274
|
+
settings: input.settings,
|
|
275
|
+
registryFingerprint: input.registryFingerprint,
|
|
276
|
+
action: async () => {
|
|
277
|
+
const stopped = await input.stop();
|
|
278
|
+
if (!stopped.ok) throw new Error(stopped.message);
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
|
|
260
282
|
reloadTelegramDaemon: async settings =>
|
|
261
283
|
await new TelegramDaemonController(settings).reload({ spawnIfStopped: false }),
|
|
262
284
|
restartTelegramDaemon: async settings =>
|
|
@@ -625,14 +647,22 @@ export function createNotificationsEditorOperations(
|
|
|
625
647
|
stopAndUnregister: async () => {
|
|
626
648
|
if (controller) await controller.enterBlockedRuntime(sessionContext());
|
|
627
649
|
runtimePrepared = true;
|
|
650
|
+
const registration = await services.readNotificationRootRegistration({
|
|
651
|
+
settings: ctx.settings,
|
|
652
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
653
|
+
});
|
|
628
654
|
const unregistered = await services.unregisterNotificationRoot({
|
|
629
655
|
settings: ctx.settings,
|
|
630
656
|
cwd: ctx.sessionManager.getCwd(),
|
|
631
657
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
658
|
+
registrationToken: registration.token,
|
|
632
659
|
});
|
|
633
|
-
if (unregistered.
|
|
634
|
-
|
|
635
|
-
|
|
660
|
+
if (unregistered.registryFingerprint !== undefined) {
|
|
661
|
+
await services.stopTelegramDaemonIfRootRegistryFenceMatches({
|
|
662
|
+
settings: ctx.settings,
|
|
663
|
+
registryFingerprint: unregistered.registryFingerprint,
|
|
664
|
+
stop: () => services.stopTelegramDaemon(ctx.settings),
|
|
665
|
+
});
|
|
636
666
|
}
|
|
637
667
|
},
|
|
638
668
|
},
|
|
@@ -70,6 +70,7 @@ import type { ToolExecutionHandle } from "./components/tool-execution";
|
|
|
70
70
|
import { StatusLineComponent } from "./components/tool-status-header";
|
|
71
71
|
import { composeToolText } from "./components/tool-transcript-format";
|
|
72
72
|
import {
|
|
73
|
+
type RecentSession,
|
|
73
74
|
WelcomeComponent,
|
|
74
75
|
type WelcomeLogoMode,
|
|
75
76
|
type LspServerInfo as WelcomeLspServerInfo,
|
|
@@ -631,15 +632,9 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
631
632
|
const modelName = this.session.model?.name ?? "Unknown";
|
|
632
633
|
const providerName = this.session.model?.provider ?? "Unknown";
|
|
633
634
|
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
sessions.map(s => ({
|
|
638
|
-
name: s.name,
|
|
639
|
-
timeAgo: s.timeAgo,
|
|
640
|
-
})),
|
|
641
|
-
),
|
|
642
|
-
);
|
|
635
|
+
// Session history is display-only. Never hold the editor and keyboard behind
|
|
636
|
+
// transcript I/O; populate the welcome trail after the interactive surface exists.
|
|
637
|
+
const recentSessions: RecentSession[] = [];
|
|
643
638
|
|
|
644
639
|
const startupQuiet = settings.get("startup.quiet");
|
|
645
640
|
const welcomeLogoMode = resolveWelcomeLogoMode(settings.get("startup.welcomeBannerMode"));
|
|
@@ -763,6 +758,31 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
763
758
|
if (this.settings.get("tasksPane.defaultVisible")) this.showTasksPane();
|
|
764
759
|
this.#syncIrcSidebarAvailabilityFromSettings();
|
|
765
760
|
this.ui.requestRender(true);
|
|
761
|
+
if (this.#welcomeComponent) {
|
|
762
|
+
const welcomeComponent = this.#welcomeComponent;
|
|
763
|
+
const recentSessionsTimer = setTimeout(() => {
|
|
764
|
+
void logger
|
|
765
|
+
.time("InteractiveMode.init:recentSessions", () =>
|
|
766
|
+
getRecentSessions(this.sessionManager.getSessionDir()),
|
|
767
|
+
)
|
|
768
|
+
.then(sessions => {
|
|
769
|
+
if (this.#welcomeComponent !== welcomeComponent) return;
|
|
770
|
+
welcomeComponent.setRecentSessions(
|
|
771
|
+
sessions.map(session => ({
|
|
772
|
+
name: session.name,
|
|
773
|
+
timeAgo: session.timeAgo,
|
|
774
|
+
})),
|
|
775
|
+
);
|
|
776
|
+
this.ui.requestRender();
|
|
777
|
+
})
|
|
778
|
+
.catch(error => {
|
|
779
|
+
logger.debug("Failed to load recent sessions for welcome screen", {
|
|
780
|
+
error: error instanceof Error ? error.message : String(error),
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
}, 0);
|
|
784
|
+
recentSessionsTimer.unref?.();
|
|
785
|
+
}
|
|
766
786
|
|
|
767
787
|
// GitHub star reminder (interactive-only). Register the decline-driven
|
|
768
788
|
// injection contributor and schedule the launch nudge after the first
|
|
@@ -1 +1 @@
|
|
|
1
|
-
- Exception: restricted `bash` is only for sanctioned GJC workflow persistence (`gjc ralplan --write ...`)
|
|
1
|
+
- Exception: restricted `bash` is only for sanctioned GJC workflow persistence (`gjc ralplan --write ...`), GJC workflow state commands (`gjc state ...`), and read-only git inspection (`git status`, `git log`, `git show`, `git diff`, `git blame`, `git rev-parse`, `git ls-files`). Pass artifacts through `GJC_RALPLAN_ARTIFACT` with `--artifact-env GJC_RALPLAN_ARTIFACT`; never use bash for product-source writes, commits, pushes, or other mutating/general shell work.
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: architect
|
|
3
3
|
description: Read-only architecture and code-review agent with severity-rated findings and status verdicts
|
|
4
|
-
tools: read, search, find, lsp, ast_grep, web_search, bash, report_finding
|
|
4
|
+
tools: read, search, find, lsp, ast_grep, web_search, bash, report_finding, irc
|
|
5
5
|
thinking-level: high
|
|
6
6
|
blocking: true
|
|
7
7
|
forkContext: allowed
|
|
8
8
|
bashAllowedPrefixes:
|
|
9
9
|
- gjc ralplan --write
|
|
10
10
|
- gjc state
|
|
11
|
+
- git status
|
|
12
|
+
- git log
|
|
13
|
+
- git show
|
|
14
|
+
- git diff
|
|
15
|
+
- git blame
|
|
16
|
+
- git rev-parse
|
|
17
|
+
- git ls-files
|
|
11
18
|
---
|
|
12
19
|
<identity>
|
|
13
20
|
You are Architect. You combine system architecture review with code-review discipline. Diagnose, analyze, and recommend with file-backed evidence. You are read-only.
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: critic
|
|
3
3
|
description: Read-only plan critic that approves only actionable, verifiable execution plans
|
|
4
|
-
tools: read, search, find, lsp, ast_grep, web_search, bash
|
|
4
|
+
tools: read, search, find, lsp, ast_grep, web_search, bash, irc
|
|
5
5
|
thinking-level: high
|
|
6
6
|
bashAllowedPrefixes:
|
|
7
7
|
- gjc ralplan --write
|
|
8
8
|
- gjc state
|
|
9
|
+
- git status
|
|
10
|
+
- git log
|
|
11
|
+
- git show
|
|
12
|
+
- git diff
|
|
13
|
+
- git blame
|
|
14
|
+
- git rev-parse
|
|
15
|
+
- git ls-files
|
|
9
16
|
---
|
|
10
17
|
<identity>
|
|
11
18
|
You are Critic. Decide whether a work plan is actionable before execution begins.
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: planner
|
|
3
3
|
description: Read-only planning agent for sequencing, acceptance criteria, risks, and handoff shape
|
|
4
|
-
tools: read, search, find, lsp, ast_grep, web_search, bash
|
|
4
|
+
tools: read, search, find, lsp, ast_grep, web_search, bash, irc
|
|
5
5
|
thinking-level: medium
|
|
6
6
|
bashAllowedPrefixes:
|
|
7
7
|
- gjc ralplan --write
|
|
8
8
|
- gjc state
|
|
9
|
+
- git status
|
|
10
|
+
- git log
|
|
11
|
+
- git show
|
|
12
|
+
- git diff
|
|
13
|
+
- git blame
|
|
14
|
+
- git rev-parse
|
|
15
|
+
- git ls-files
|
|
9
16
|
---
|
|
10
17
|
<identity>
|
|
11
18
|
You are Planner. Turn requests into actionable work plans. You plan; you do not implement.
|
|
@@ -24,6 +24,7 @@ Optimize for correctness first, maintainability second, and brevity third. Prefe
|
|
|
24
24
|
- Informational questions are answer-only/read-only unless the user explicitly requests a change, command, or execution.
|
|
25
25
|
- Vague requirements use `/skill:deep-interview`: a requirements-only workflow that must not mutate product code. Its spec hands off as deep-interview → ralplan consensus → pending approval → separately approved execution.
|
|
26
26
|
- Clear work with non-trivial architecture or sequencing risk uses `/skill:ralplan --deliberate` and stops pending approval.
|
|
27
|
+
- Deep-interview state is runtime-owned: diagnose with `gjc deep-interview sanity-check`, inspect only the affected bounded selector, and repair through typed operations using CLI-generated/edited drafts (`draft create|edit|show|check|rebase|discard`) consumed with `--draft-id`. Inline JSON request flags are compatibility-only; normal flow must never reconstruct a payload or full envelope.
|
|
27
28
|
- Use `/skill:ultragoal` for durable goal ledgers and `/skill:team` for approved coordinated persistent work.
|
|
28
29
|
- Delegate large implementation slices to `executor`; use `planner`, `architect`, or `critic` for bounded planning and review.
|
|
29
30
|
- Active skills are authoritative: never ignore an invoked skill; read the full skill text and follow it exactly.
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Lightweight utilities for calling MCP servers directly via HTTP
|
|
5
5
|
* without maintaining persistent connections.
|
|
6
6
|
*/
|
|
7
|
-
// biome-ignore assist/source/organizeImports: Keep independent MCP security imports on separate merge anchors.
|
|
8
|
-
import { cancelMCPStream, MCP_HTTP_TIMEOUT_MS, MCP_MAX_CONTENT_BYTES, readMCPResponseText } from "./content-limits";
|
|
9
7
|
import { logger } from "@gajae-code/utils";
|
|
8
|
+
import { cancelMCPStream, MCP_HTTP_TIMEOUT_MS, MCP_MAX_CONTENT_BYTES, readMCPResponseText } from "./content-limits";
|
|
9
|
+
import { redactMCPDiagnosticValue, redactMCPEndpoint } from "./redaction";
|
|
10
10
|
|
|
11
11
|
/** Parse SSE response format (lines starting with "data: ") */
|
|
12
12
|
export function parseSSE(text: string): unknown {
|
|
@@ -82,7 +82,7 @@ export async function callMCP<T = unknown>(
|
|
|
82
82
|
if (!response.ok) {
|
|
83
83
|
cancelMCPStream(response.body);
|
|
84
84
|
const errorMsg = `MCP request failed: ${response.status} ${response.statusText}`;
|
|
85
|
-
logger.error(errorMsg, { url, method, params });
|
|
85
|
+
logger.error(errorMsg, { url: redactMCPEndpoint(url), method, params: redactMCPDiagnosticValue(params) });
|
|
86
86
|
throw new Error(errorMsg);
|
|
87
87
|
}
|
|
88
88
|
|
|
@@ -90,7 +90,10 @@ export async function callMCP<T = unknown>(
|
|
|
90
90
|
const result = parseSSE(text) as JsonRpcResponse<T> | null;
|
|
91
91
|
|
|
92
92
|
if (!result) {
|
|
93
|
-
logger.error("Failed to parse MCP response", {
|
|
93
|
+
logger.error("Failed to parse MCP response", {
|
|
94
|
+
url: redactMCPEndpoint(url),
|
|
95
|
+
method,
|
|
96
|
+
});
|
|
94
97
|
throw new Error("Failed to parse MCP response");
|
|
95
98
|
}
|
|
96
99
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const REDACTED = "<redacted>";
|
|
2
|
+
const SENSITIVE_KEY_PATTERN = /(?:token|secret|key|credential|password|authorization|auth|bearer|cookie|session)/i;
|
|
3
|
+
|
|
4
|
+
export function redactMCPEndpoint(value: string | undefined): string | undefined {
|
|
5
|
+
if (!value) return value;
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(value);
|
|
8
|
+
if (url.username) url.username = REDACTED;
|
|
9
|
+
if (url.password) url.password = REDACTED;
|
|
10
|
+
if (url.pathname !== "/") url.pathname = `/${REDACTED}`;
|
|
11
|
+
for (const key of Array.from(url.searchParams.keys())) {
|
|
12
|
+
url.searchParams.set(key, REDACTED);
|
|
13
|
+
}
|
|
14
|
+
url.hash = "";
|
|
15
|
+
return url.toString();
|
|
16
|
+
} catch {
|
|
17
|
+
return REDACTED;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function redactMCPDiagnosticValue(value: unknown): unknown {
|
|
22
|
+
if (Array.isArray(value)) return value.map(redactMCPDiagnosticValue);
|
|
23
|
+
if (!value || typeof value !== "object") return value;
|
|
24
|
+
return Object.fromEntries(
|
|
25
|
+
Object.entries(value).map(([key, entry]) => [
|
|
26
|
+
key,
|
|
27
|
+
SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : redactMCPDiagnosticValue(entry),
|
|
28
|
+
]),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -11,20 +11,30 @@ export function brokerIdentityPath(agentDir: string): string {
|
|
|
11
11
|
export async function getBrokerIdentityKey(agentDir: string): Promise<string> {
|
|
12
12
|
const file = brokerIdentityPath(agentDir);
|
|
13
13
|
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
while (true) {
|
|
15
|
+
try {
|
|
16
|
+
const key = (await fs.readFile(file, "utf8")).trim();
|
|
17
|
+
if (/^[0-9a-f]{64}$/i.test(key)) return key;
|
|
18
|
+
throw new Error(`Invalid broker identity key at ${file}`);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
21
|
+
}
|
|
22
|
+
|
|
20
23
|
const key = randomBytes(32).toString("hex");
|
|
24
|
+
const temporary = path.join(path.dirname(file), `.broker.identity-${randomBytes(16).toString("hex")}`);
|
|
21
25
|
try {
|
|
22
|
-
await fs.writeFile(
|
|
23
|
-
await fs.chmod(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
await fs.writeFile(temporary, `${key}\n`, { encoding: "utf8", mode: MODE, flag: "wx" });
|
|
27
|
+
await fs.chmod(temporary, MODE);
|
|
28
|
+
try {
|
|
29
|
+
// Link publishes a fully written key without exposing a partial target.
|
|
30
|
+
// Same-directory hard-link support is required (standard on Linux, macOS, and Windows NTFS).
|
|
31
|
+
await fs.link(temporary, file);
|
|
32
|
+
return key;
|
|
33
|
+
} catch (linkError) {
|
|
34
|
+
if ((linkError as NodeJS.ErrnoException).code !== "EEXIST") throw linkError;
|
|
35
|
+
}
|
|
36
|
+
} finally {
|
|
37
|
+
await fs.rm(temporary, { force: true });
|
|
28
38
|
}
|
|
29
39
|
}
|
|
30
40
|
}
|
|
@@ -131,6 +131,7 @@ function compactEvents(events: SessionIndexEvent[]): SessionIndexEvent[] {
|
|
|
131
131
|
|
|
132
132
|
async function appendSync(file: string, value: string): Promise<void> {
|
|
133
133
|
const h = await fs.open(file, "a", 0o600);
|
|
134
|
+
let failure: { error: unknown } | undefined;
|
|
134
135
|
try {
|
|
135
136
|
const data = Buffer.from(`${value}\n`);
|
|
136
137
|
for (let offset = 0; offset < data.length; ) {
|
|
@@ -139,9 +140,18 @@ async function appendSync(file: string, value: string): Promise<void> {
|
|
|
139
140
|
offset += bytesWritten;
|
|
140
141
|
}
|
|
141
142
|
await h.sync();
|
|
142
|
-
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
failure = { error };
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
143
147
|
await h.close();
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// Bun may report EBADF when concurrent child-pipe teardown has already
|
|
150
|
+
// released a fully written and fsynced append handle. The descriptor is
|
|
151
|
+
// closed in that case; every other close failure remains fatal.
|
|
152
|
+
if ((error as NodeJS.ErrnoException).code !== "EBADF" && failure === undefined) failure = { error };
|
|
144
153
|
}
|
|
154
|
+
if (failure !== undefined) throw failure.error;
|
|
145
155
|
}
|
|
146
156
|
|
|
147
157
|
async function syncDirectory(file: string): Promise<void> {
|
|
@@ -32,11 +32,13 @@ export type ChatDaemonAction = "stop" | "reload";
|
|
|
32
32
|
* authority declaration contract with bounded frame-delivery acknowledgement.
|
|
33
33
|
* Generation 11 accepts typed retained exact-unlink cleanup authority — a
|
|
34
34
|
* concrete detached quarantine plus a proven-absent canonical lock pathname —
|
|
35
|
-
* when deleting an observed owner-lock lease.
|
|
35
|
+
* when deleting an observed owner-lock lease. Generation 12 refreshes retained
|
|
36
|
+
* native path and process authority semantics. Generation 13 rejects special
|
|
37
|
+
* files before retained native authority opens.
|
|
36
38
|
*/
|
|
37
39
|
export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
|
|
38
|
-
discord:
|
|
39
|
-
slack:
|
|
40
|
+
discord: 13,
|
|
41
|
+
slack: 13,
|
|
40
42
|
};
|
|
41
43
|
|
|
42
44
|
export function chatDaemonGeneration(kind: ChatDaemonKind): number {
|
|
@@ -12,6 +12,7 @@ export interface DaemonPaths {
|
|
|
12
12
|
dir: string;
|
|
13
13
|
lock: string;
|
|
14
14
|
state: string;
|
|
15
|
+
heartbeat: string;
|
|
15
16
|
ownership: string;
|
|
16
17
|
roots: string;
|
|
17
18
|
steal: string;
|
|
@@ -26,6 +27,7 @@ export function daemonPaths(agentDir: string): DaemonPaths {
|
|
|
26
27
|
lock: path.join(dir, "telegram-daemon.lock"),
|
|
27
28
|
state: path.join(dir, "telegram-daemon.state.json"),
|
|
28
29
|
ownership: path.join(dir, "telegram-daemon.ownership"),
|
|
30
|
+
heartbeat: path.join(dir, "telegram-daemon.heartbeat.json"),
|
|
29
31
|
roots: path.join(dir, "telegram-daemon.roots.json"),
|
|
30
32
|
steal: path.join(dir, "telegram-daemon.steal"),
|
|
31
33
|
aliases: path.join(dir, "telegram-callback-aliases.json"),
|
package/src/sdk/bus/index.ts
CHANGED
|
@@ -89,6 +89,8 @@ import {
|
|
|
89
89
|
type EnsureDaemonResult,
|
|
90
90
|
endpointAuthorityDigest,
|
|
91
91
|
ensureTelegramDaemonRunningDetailed,
|
|
92
|
+
type RegisterNotificationRootResult,
|
|
93
|
+
unregisterNotificationRoot,
|
|
92
94
|
} from "./telegram-daemon";
|
|
93
95
|
|
|
94
96
|
// ===========================================================================
|
|
@@ -909,6 +911,8 @@ interface SessionRuntime {
|
|
|
909
911
|
hostStopped: boolean;
|
|
910
912
|
serverStopped: boolean;
|
|
911
913
|
brokerRegistrationReleased: boolean;
|
|
914
|
+
/** Managed Telegram root registration released during terminal teardown. */
|
|
915
|
+
notificationRootRegistration?: { settings: Settings; cwd: string; registrationToken?: string };
|
|
912
916
|
verbosity: "lean" | "verbose";
|
|
913
917
|
sessionTag: string;
|
|
914
918
|
/** Whether the agent loop is currently running (drives the typing indicator). */
|
|
@@ -2832,6 +2836,7 @@ export function createNotificationsExtension(
|
|
|
2832
2836
|
settings: Settings;
|
|
2833
2837
|
cwd: string;
|
|
2834
2838
|
sessionId: string;
|
|
2839
|
+
onRegistered?: (registration: RegisterNotificationRootResult) => void;
|
|
2835
2840
|
}) => Promise<EnsureDaemonResult>;
|
|
2836
2841
|
ensureProviderDaemon?: (provider: "discord" | "slack", settings: Settings) => Promise<unknown>;
|
|
2837
2842
|
/** Suppress auto-delivery for a GJC-spawned child under `sessionScope=primary`. */
|
|
@@ -2873,13 +2878,24 @@ export function createNotificationsExtension(
|
|
|
2873
2878
|
settings: Settings,
|
|
2874
2879
|
cwd: string,
|
|
2875
2880
|
id: string,
|
|
2881
|
+
onRegistered?: (registrationToken: string) => void,
|
|
2876
2882
|
): Promise<"ready" | "blocked_identity"> {
|
|
2877
2883
|
if (options.ensureTelegramDaemon) {
|
|
2878
|
-
return (await options.ensureTelegramDaemon({
|
|
2884
|
+
return (await options.ensureTelegramDaemon({
|
|
2885
|
+
settings,
|
|
2886
|
+
cwd,
|
|
2887
|
+
sessionId: id,
|
|
2888
|
+
onRegistered: registration => onRegistered?.(registration.token),
|
|
2889
|
+
})) === "blocked"
|
|
2879
2890
|
? "blocked_identity"
|
|
2880
2891
|
: "ready";
|
|
2881
2892
|
}
|
|
2882
|
-
return (await ensureTelegramDaemonRunningDetailed({
|
|
2893
|
+
return (await ensureTelegramDaemonRunningDetailed({
|
|
2894
|
+
settings,
|
|
2895
|
+
cwd,
|
|
2896
|
+
sessionId: id,
|
|
2897
|
+
onRegistered: registration => onRegistered?.(registration.token),
|
|
2898
|
+
})) === "blocked_identity"
|
|
2883
2899
|
? "blocked_identity"
|
|
2884
2900
|
: "ready";
|
|
2885
2901
|
}
|
|
@@ -2888,9 +2904,10 @@ export function createNotificationsExtension(
|
|
|
2888
2904
|
cfg: NotificationConfig,
|
|
2889
2905
|
cwd: string,
|
|
2890
2906
|
id: string,
|
|
2907
|
+
onRegistered?: (registrationToken: string) => void,
|
|
2891
2908
|
): Promise<boolean> {
|
|
2892
2909
|
if (isTelegramConfigured(cfg)) {
|
|
2893
|
-
if ((await ensureTelegramOwner(settings, cwd, id)) === "blocked_identity") return false;
|
|
2910
|
+
if ((await ensureTelegramOwner(settings, cwd, id, onRegistered)) === "blocked_identity") return false;
|
|
2894
2911
|
}
|
|
2895
2912
|
await ensureConfiguredProviderDaemons(settings, cfg, options.ensureProviderDaemon);
|
|
2896
2913
|
return true;
|
|
@@ -3040,6 +3057,20 @@ export function createNotificationsExtension(
|
|
|
3040
3057
|
hostStopped: rt.hostStopped && rt.serverStopped,
|
|
3041
3058
|
brokerRegistrationReleased: rt.brokerRegistrationReleased,
|
|
3042
3059
|
});
|
|
3060
|
+
if (rt.notificationRootRegistration) {
|
|
3061
|
+
try {
|
|
3062
|
+
await unregisterNotificationRoot({
|
|
3063
|
+
settings: rt.notificationRootRegistration.settings,
|
|
3064
|
+
cwd: rt.notificationRootRegistration.cwd,
|
|
3065
|
+
sessionId: id,
|
|
3066
|
+
registrationToken: rt.notificationRootRegistration.registrationToken,
|
|
3067
|
+
});
|
|
3068
|
+
rt.notificationRootRegistration = undefined;
|
|
3069
|
+
} catch (e) {
|
|
3070
|
+
ownerReleaseFailures.push(e);
|
|
3071
|
+
logger.warn(`notifications: Telegram root unregister failed: ${String(e)}`);
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3043
3074
|
if (ownerReleaseFailures.length > 0) {
|
|
3044
3075
|
cleanupRetries.set(id, rt);
|
|
3045
3076
|
throw new AggregateError(ownerReleaseFailures, `SDK notification runtime ${id} owner release failed.`);
|
|
@@ -3641,6 +3672,7 @@ export function createNotificationsExtension(
|
|
|
3641
3672
|
inFlightTools: new Map<string, { toolName: string; args: unknown }>(),
|
|
3642
3673
|
deferredGatePresentations: [],
|
|
3643
3674
|
deferredInboundControls: [],
|
|
3675
|
+
notificationRootRegistration: undefined,
|
|
3644
3676
|
};
|
|
3645
3677
|
const initializedRuntime = runtime;
|
|
3646
3678
|
runtimes.set(id, initializedRuntime);
|
|
@@ -4130,12 +4162,19 @@ export function createNotificationsExtension(
|
|
|
4130
4162
|
}
|
|
4131
4163
|
if (notificationsEnabledForSession && settingsAvailable && settings) {
|
|
4132
4164
|
try {
|
|
4133
|
-
|
|
4165
|
+
let registrationToken: string | undefined;
|
|
4166
|
+
if (
|
|
4167
|
+
!(await ensureConfiguredDaemonOwners(settings, cfg, ctx.cwd, id, token => {
|
|
4168
|
+
registrationToken = token;
|
|
4169
|
+
}))
|
|
4170
|
+
) {
|
|
4134
4171
|
const result = failLifecycleStartup("failed", "Telegram daemon ownership is blocked.");
|
|
4135
4172
|
finishStartup(result);
|
|
4136
4173
|
await cleanupAbandonedStartup();
|
|
4137
4174
|
return result;
|
|
4138
4175
|
}
|
|
4176
|
+
if (isTelegramConfigured(cfg))
|
|
4177
|
+
runtime.notificationRootRegistration = { settings, cwd: ctx.cwd, registrationToken };
|
|
4139
4178
|
} catch (error) {
|
|
4140
4179
|
const result = failLifecycleStartup("failed", error);
|
|
4141
4180
|
finishStartup(result);
|
|
@@ -4434,7 +4473,23 @@ export function createNotificationsExtension(
|
|
|
4434
4473
|
const { settings, settingsAvailable } = resolveSettings(options.settings);
|
|
4435
4474
|
if (!settingsAvailable || !settings) return "blocked_identity";
|
|
4436
4475
|
try {
|
|
4437
|
-
|
|
4476
|
+
let registrationToken: string | undefined;
|
|
4477
|
+
const result = await ensureTelegramOwner(settings, binding.cwd, binding.sessionId, token => {
|
|
4478
|
+
registrationToken = token;
|
|
4479
|
+
});
|
|
4480
|
+
const runtime = runtimes.get(binding.sessionId);
|
|
4481
|
+
const configured = isTelegramConfigured(resolveSettings(options.settings).cfg);
|
|
4482
|
+
if (result === "ready" && runtime && !runtime.stopping && configured) {
|
|
4483
|
+
runtime.notificationRootRegistration = { settings, cwd: binding.cwd, registrationToken };
|
|
4484
|
+
} else if (registrationToken !== undefined) {
|
|
4485
|
+
await unregisterNotificationRoot({
|
|
4486
|
+
settings,
|
|
4487
|
+
cwd: binding.cwd,
|
|
4488
|
+
sessionId: binding.sessionId,
|
|
4489
|
+
registrationToken,
|
|
4490
|
+
});
|
|
4491
|
+
}
|
|
4492
|
+
return result;
|
|
4438
4493
|
} catch {
|
|
4439
4494
|
return "failed";
|
|
4440
4495
|
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
tokenFingerprint,
|
|
29
29
|
} from "./config";
|
|
30
30
|
import { type DaemonPaths, daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
|
|
31
|
+
import { type OwnerFreshnessSnapshot, readOwnerFreshnessSnapshot, type TelegramDaemonFs } from "./telegram-daemon";
|
|
31
32
|
import { DAEMON_GENERATION } from "./telegram-daemon-contract";
|
|
32
33
|
|
|
33
34
|
const DEFAULT_API_BASE = "https://api.telegram.org";
|
|
@@ -578,10 +579,21 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
|
|
|
578
579
|
checks.push({ name: "config", level: "ok", detail: "enabled with at least one configured adapter" });
|
|
579
580
|
}
|
|
580
581
|
|
|
581
|
-
// Daemon ownership state (offline;
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
582
|
+
// Daemon ownership state (offline; corrupt or unreadable state degrades to a
|
|
583
|
+
// warning instead of making the health command itself fail).
|
|
584
|
+
let daemonStateUnreadable = false;
|
|
585
|
+
let snapshot: OwnerFreshnessSnapshot;
|
|
586
|
+
try {
|
|
587
|
+
snapshot = await readOwnerFreshnessSnapshot({
|
|
588
|
+
settings: opts.settings,
|
|
589
|
+
fs: fs as unknown as TelegramDaemonFs,
|
|
590
|
+
});
|
|
591
|
+
} catch {
|
|
592
|
+
daemonStateUnreadable = true;
|
|
593
|
+
snapshot = { ownerTag: null, effectiveHeartbeatAt: undefined, legacyEmbedded: false, state: undefined };
|
|
594
|
+
}
|
|
595
|
+
const state = snapshot.state ? parseDaemonState(JSON.stringify(snapshot.state)) : undefined;
|
|
596
|
+
const heartbeatAt = finiteNonNegativeNumber(snapshot.effectiveHeartbeatAt);
|
|
585
597
|
const daemon: DaemonHealth = {
|
|
586
598
|
present: Boolean(state),
|
|
587
599
|
ownerId: state?.ownerId,
|
|
@@ -600,7 +612,9 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
|
|
|
600
612
|
currentGeneration: DAEMON_GENERATION,
|
|
601
613
|
generationRelation: daemonGenerationRelation(state),
|
|
602
614
|
};
|
|
603
|
-
if (
|
|
615
|
+
if (daemonStateUnreadable) {
|
|
616
|
+
checks.push({ name: "daemon", level: "warn", detail: "daemon ownership record is corrupt or unreadable" });
|
|
617
|
+
} else if (!state) {
|
|
604
618
|
checks.push({ name: "daemon", level: "ok", detail: "no daemon ownership record (none running)" });
|
|
605
619
|
} else if (!daemon.alive) {
|
|
606
620
|
checks.push({
|
|
@@ -74,7 +74,7 @@ export class NotificationOperatorRuntime {
|
|
|
74
74
|
#stopRequested = false;
|
|
75
75
|
#activeAbort: AbortController | undefined;
|
|
76
76
|
#intervals = new Map<string, OperatorIntervalHandle>();
|
|
77
|
-
#exclusive = new
|
|
77
|
+
#exclusive = new Map<string, Promise<void>>();
|
|
78
78
|
|
|
79
79
|
#deps: NotificationOperatorTimerDeps;
|
|
80
80
|
|
|
@@ -130,7 +130,7 @@ export class NotificationOperatorRuntime {
|
|
|
130
130
|
|
|
131
131
|
stopInterval(name: string): void {
|
|
132
132
|
const timer = this.#intervals.get(name);
|
|
133
|
-
if (
|
|
133
|
+
if (timer === undefined) return;
|
|
134
134
|
const clearIntervalImpl = this.#deps.clearIntervalImpl ?? clearInterval;
|
|
135
135
|
clearIntervalImpl(timer);
|
|
136
136
|
this.#intervals.delete(name);
|
|
@@ -142,14 +142,40 @@ export class NotificationOperatorRuntime {
|
|
|
142
142
|
|
|
143
143
|
async runExclusive(name: string, fn: () => Promise<void>): Promise<void> {
|
|
144
144
|
if (this.#exclusive.has(name)) return;
|
|
145
|
-
|
|
145
|
+
const completion = Promise.withResolvers<void>();
|
|
146
|
+
this.#exclusive.set(name, completion.promise);
|
|
146
147
|
try {
|
|
147
|
-
|
|
148
|
+
try {
|
|
149
|
+
void fn().then(completion.resolve, completion.reject);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
completion.reject(error);
|
|
152
|
+
}
|
|
153
|
+
await completion.promise;
|
|
148
154
|
} finally {
|
|
149
|
-
this.#exclusive.delete(name);
|
|
155
|
+
if (this.#exclusive.get(name) === completion.promise) this.#exclusive.delete(name);
|
|
150
156
|
}
|
|
151
157
|
}
|
|
152
158
|
|
|
159
|
+
async joinExclusive(name: string, timeoutMs: number): Promise<boolean> {
|
|
160
|
+
const completion = this.#exclusive.get(name);
|
|
161
|
+
if (!completion) return true;
|
|
162
|
+
const setTimeoutImpl = this.#deps.setTimeoutImpl ?? setTimeout;
|
|
163
|
+
const clearTimeoutImpl = this.#deps.clearTimeoutImpl ?? clearTimeout;
|
|
164
|
+
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
165
|
+
const timer = setTimeoutImpl(() => resolve(false), timeoutMs);
|
|
166
|
+
void completion.then(
|
|
167
|
+
() => {
|
|
168
|
+
clearTimeoutImpl(timer);
|
|
169
|
+
resolve(true);
|
|
170
|
+
},
|
|
171
|
+
() => {
|
|
172
|
+
clearTimeoutImpl(timer);
|
|
173
|
+
resolve(true);
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
return await promise;
|
|
177
|
+
}
|
|
178
|
+
|
|
153
179
|
sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
154
180
|
return new Promise<void>(resolve => {
|
|
155
181
|
if (signal?.aborted) return resolve();
|