@gajae-code/coding-agent 0.7.2 → 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 +38 -0
- package/dist/types/cli/mcp-cli.d.ts +25 -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/deep-interview/plaintext-gate-guard.d.ts +11 -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/operator-runtime.d.ts +52 -0
- package/dist/types/notifications/telegram-daemon.d.ts +54 -16
- package/dist/types/notifications/topic-registry.d.ts +2 -0
- package/dist/types/tools/composer-bash-policy.d.ts +14 -0
- package/dist/types/web/insane/url-guard.d.ts +6 -3
- package/dist/types/web/scrapers/types.d.ts +5 -0
- package/dist/types/web/scrapers/utils.d.ts +7 -1
- package/package.json +7 -7
- package/src/cli/mcp-cli.ts +272 -0
- package/src/cli.ts +6 -2
- package/src/commands/mcp.ts +117 -0
- package/src/config/keybindings.ts +2 -2
- 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/team/SKILL.md +3 -2
- package/src/extensibility/extensions/runner.ts +1 -0
- package/src/gjc-runtime/tmux-common.ts +3 -1
- package/src/gjc-runtime/ultragoal-guard.ts +25 -8
- package/src/hooks/skill-state.ts +57 -0
- package/src/internal-urls/docs-index.generated.ts +10 -7
- 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/selector-controller.ts +3 -0
- package/src/modes/interactive-mode.ts +2 -1
- 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/notifications/operator-runtime.ts +171 -0
- package/src/notifications/telegram-daemon.ts +347 -251
- package/src/notifications/topic-registry.ts +5 -0
- 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 +18 -2
- package/src/web/insane/url-guard.ts +18 -14
- package/src/web/scrapers/types.ts +143 -45
- package/src/web/scrapers/utils.ts +70 -19
|
@@ -153,6 +153,14 @@ function jsonResponse(status: number, body: unknown): Response {
|
|
|
153
153
|
});
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
function isBridgeControllerOwner(options: BridgeFetchHandlerOptions, ownerToken: string): boolean {
|
|
157
|
+
if (!ownerToken) return false;
|
|
158
|
+
const ownerTokens = [options.permissionBroker?.ownerToken, options.uiBroker?.ownerToken].filter(
|
|
159
|
+
(token): token is string => typeof token === "string" && token.length > 0,
|
|
160
|
+
);
|
|
161
|
+
return ownerTokens.length > 0 && ownerTokens.every(token => token === ownerToken);
|
|
162
|
+
}
|
|
163
|
+
|
|
156
164
|
function parseBridgeScopes(value: string | undefined): readonly BridgeCommandScope[] {
|
|
157
165
|
if (!value?.trim()) return DEFAULT_BRIDGE_SCOPES;
|
|
158
166
|
const allowed = new Set(BRIDGE_COMMAND_SCOPES);
|
|
@@ -425,6 +433,9 @@ export function createBridgeFetchHandler(options: BridgeFetchHandlerOptions): (r
|
|
|
425
433
|
"answer" in payload &&
|
|
426
434
|
(correlationId === (payload as RpcWorkflowGateResponse).gate_id || correlationId.startsWith("wg_"))
|
|
427
435
|
) {
|
|
436
|
+
if (!isBridgeControllerOwner(options, ownerToken)) {
|
|
437
|
+
return jsonResponse(403, { status: "rejected", code: "not_controller" });
|
|
438
|
+
}
|
|
428
439
|
try {
|
|
429
440
|
const resolution = await options.unattendedControlPlane?.resolveGate({
|
|
430
441
|
gate_id: (payload as RpcWorkflowGateResponse).gate_id,
|
|
@@ -18,6 +18,7 @@ type ConfigurableEditorAction = Extract<
|
|
|
18
18
|
| "app.editor.external"
|
|
19
19
|
| "app.history.search"
|
|
20
20
|
| "app.message.dequeue"
|
|
21
|
+
| "app.message.followUp"
|
|
21
22
|
| "app.message.queue"
|
|
22
23
|
| "app.clipboard.pasteImage"
|
|
23
24
|
| "app.clipboard.copyPrompt"
|
|
@@ -40,6 +41,7 @@ const CONFIGURABLE_EDITOR_ACTIONS = [
|
|
|
40
41
|
"app.thinking.toggle",
|
|
41
42
|
"app.editor.external",
|
|
42
43
|
"app.history.search",
|
|
44
|
+
"app.message.followUp",
|
|
43
45
|
"app.message.queue",
|
|
44
46
|
"app.message.dequeue",
|
|
45
47
|
"app.clipboard.pasteImage",
|
|
@@ -8,6 +8,7 @@ import { shortenPath } from "../../tools/render-utils";
|
|
|
8
8
|
import * as git from "../../utils/git";
|
|
9
9
|
import { sanitizeStatusText } from "../shared";
|
|
10
10
|
import { getContextUsageLevel, getContextUsageThemeColor } from "./status-line/context-thresholds";
|
|
11
|
+
import { resolveCurrentBranch } from "./status-line/git-utils";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Footer component that shows pwd, token stats, and context usage
|
|
@@ -103,9 +104,7 @@ export class FooterComponent implements Component {
|
|
|
103
104
|
return this.#cachedBranch;
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
|
|
107
|
-
this.#cachedBranch =
|
|
108
|
-
headState === null ? null : headState.kind === "ref" ? (headState.branchName ?? headState.ref) : "detached";
|
|
107
|
+
this.#cachedBranch = resolveCurrentBranch(getProjectDir()).branch;
|
|
109
108
|
return this.#cachedBranch;
|
|
110
109
|
}
|
|
111
110
|
|
|
@@ -221,6 +221,8 @@ export class ModelSelectorComponent extends Container {
|
|
|
221
221
|
#scopedModels: ReadonlyArray<ScopedModelItem>;
|
|
222
222
|
#temporaryOnly: boolean;
|
|
223
223
|
#currentModel?: Model;
|
|
224
|
+
#currentThinkingLevel?: ThinkingLevel;
|
|
225
|
+
#activeModelProfile?: string;
|
|
224
226
|
#isFastForProvider: (provider?: string) => boolean = () => false;
|
|
225
227
|
#isFastForSubagentProvider: (provider?: string) => boolean = () => false;
|
|
226
228
|
#pendingActionItem?: ModelItem | CanonicalModelItem;
|
|
@@ -258,6 +260,8 @@ export class ModelSelectorComponent extends Container {
|
|
|
258
260
|
sessionId?: string;
|
|
259
261
|
isFastForProvider?: (provider?: string) => boolean;
|
|
260
262
|
isFastForSubagentProvider?: (provider?: string) => boolean;
|
|
263
|
+
currentThinkingLevel?: ThinkingLevel;
|
|
264
|
+
activeModelProfile?: string;
|
|
261
265
|
},
|
|
262
266
|
) {
|
|
263
267
|
super();
|
|
@@ -271,6 +275,8 @@ export class ModelSelectorComponent extends Container {
|
|
|
271
275
|
this.#temporaryOnly = options?.temporaryOnly ?? false;
|
|
272
276
|
this.#authSessionId = options?.sessionId;
|
|
273
277
|
this.#currentModel = _currentModel;
|
|
278
|
+
this.#currentThinkingLevel = options?.currentThinkingLevel;
|
|
279
|
+
this.#activeModelProfile = options?.activeModelProfile;
|
|
274
280
|
this.#isFastForProvider = options?.isFastForProvider ?? (() => false);
|
|
275
281
|
this.#isFastForSubagentProvider = options?.isFastForSubagentProvider ?? (() => false);
|
|
276
282
|
const initialSearchInput = options?.initialSearchInput;
|
|
@@ -367,6 +373,12 @@ export class ModelSelectorComponent extends Container {
|
|
|
367
373
|
};
|
|
368
374
|
}
|
|
369
375
|
}
|
|
376
|
+
if (this.#activeModelProfile && this.#currentModel) {
|
|
377
|
+
this.#roles.default = {
|
|
378
|
+
model: this.#currentModel,
|
|
379
|
+
thinkingLevel: this.#currentThinkingLevel ?? ThinkingLevel.Inherit,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
370
382
|
}
|
|
371
383
|
|
|
372
384
|
#sortModels(models: ModelItem[]): void {
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { GitHeadState } from "../../../utils/git";
|
|
2
|
+
import * as git from "../../../utils/git";
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Extract "owner/repo" from a GitHub remote URL.
|
|
3
6
|
* Handles HTTPS, SSH (scp-style), and git:// protocols.
|
|
@@ -40,3 +43,25 @@ export function canReuseCachedPr(
|
|
|
40
43
|
): boolean {
|
|
41
44
|
return cachedPr !== undefined && currentContext !== null && isSamePrCacheContext(cachedContext, currentContext);
|
|
42
45
|
}
|
|
46
|
+
|
|
47
|
+
export interface CurrentBranchState {
|
|
48
|
+
readonly branch: string | null;
|
|
49
|
+
readonly repoId: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveCurrentBranch(
|
|
53
|
+
cwd: string,
|
|
54
|
+
resolveHead: (cwd: string) => GitHeadState | null = git.head.resolveSync,
|
|
55
|
+
): CurrentBranchState {
|
|
56
|
+
try {
|
|
57
|
+
const head = resolveHead(cwd);
|
|
58
|
+
if (!head) return { branch: null, repoId: null };
|
|
59
|
+
return {
|
|
60
|
+
branch: head.kind === "ref" ? (head.branchName ?? head.ref) : "detached",
|
|
61
|
+
repoId: head.headPath,
|
|
62
|
+
};
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error instanceof Error) return { branch: null, repoId: null };
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
createPrCacheContext,
|
|
21
21
|
isSamePrCacheContext,
|
|
22
22
|
type PrCacheContext,
|
|
23
|
+
resolveCurrentBranch,
|
|
23
24
|
} from "./status-line/git-utils";
|
|
24
25
|
import { getPreset } from "./status-line/presets";
|
|
25
26
|
import { renderSegment, type SegmentContext } from "./status-line/segments";
|
|
@@ -303,19 +304,13 @@ export class StatusLineComponent implements Component {
|
|
|
303
304
|
this.#cachedPrContext = undefined;
|
|
304
305
|
}
|
|
305
306
|
#getCurrentBranch(): string | null {
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === gitHeadPath) {
|
|
307
|
+
const current = resolveCurrentBranch(getProjectDir());
|
|
308
|
+
if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === current.repoId) {
|
|
309
309
|
return this.#cachedBranch;
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
-
this.#cachedBranchRepoId =
|
|
313
|
-
|
|
314
|
-
this.#cachedBranch = null;
|
|
315
|
-
return null;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
this.#cachedBranch = head.kind === "ref" ? (head.branchName ?? head.ref) : "detached";
|
|
312
|
+
this.#cachedBranchRepoId = current.repoId;
|
|
313
|
+
this.#cachedBranch = current.branch;
|
|
319
314
|
|
|
320
315
|
return this.#cachedBranch ?? null;
|
|
321
316
|
}
|
|
@@ -680,7 +675,11 @@ export class StatusLineComponent implements Component {
|
|
|
680
675
|
const effectiveSettings = this.#resolveSettings();
|
|
681
676
|
const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
|
|
682
677
|
|
|
683
|
-
|
|
678
|
+
// Use the subtle surface tone (the same elevated background as user-message
|
|
679
|
+
// bubbles) instead of the heavy `statusLineBg` block, so the rail layers
|
|
680
|
+
// just above the base background as a quiet zone rather than a solid bar.
|
|
681
|
+
// Resolving through a semantic slot keeps it correct across every theme.
|
|
682
|
+
const bgAnsi = theme.getBgAnsi("userMessageBg");
|
|
684
683
|
const fgAnsi = theme.getFgAnsi("text");
|
|
685
684
|
const sepAnsi = theme.getFgAnsi("statusLineSep");
|
|
686
685
|
|
|
@@ -74,9 +74,8 @@ export class WelcomeComponent implements Component {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
render(termWidth: number): string[] {
|
|
77
|
-
// Box dimensions
|
|
78
|
-
const
|
|
79
|
-
const boxWidth = Math.min(maxWidth, Math.max(0, termWidth - 2));
|
|
77
|
+
// Box dimensions track the live viewport so wide terminals feel intentionally full-screen.
|
|
78
|
+
const boxWidth = Math.max(0, termWidth - 2);
|
|
80
79
|
if (boxWidth < 4) {
|
|
81
80
|
return [];
|
|
82
81
|
}
|
|
@@ -745,6 +745,9 @@ export class SelectorController {
|
|
|
745
745
|
{
|
|
746
746
|
...options,
|
|
747
747
|
sessionId: this.ctx.session.sessionId,
|
|
748
|
+
currentThinkingLevel: this.ctx.session.thinkingLevel,
|
|
749
|
+
activeModelProfile:
|
|
750
|
+
this.ctx.session.getActiveModelProfile?.() ?? this.ctx.settings.get("modelProfile.default"),
|
|
748
751
|
isFastForProvider: provider => this.ctx.session.isFastForProvider(provider),
|
|
749
752
|
isFastForSubagentProvider: provider => this.ctx.session.isFastForSubagentProvider(provider),
|
|
750
753
|
},
|
|
@@ -155,7 +155,7 @@ function getShellInputPrefix(isNoContext: boolean): string {
|
|
|
155
155
|
|
|
156
156
|
function configureDefaultComposerChrome(editor: CustomEditor): void {
|
|
157
157
|
editor.setBorderVisible(true);
|
|
158
|
-
editor.setBorderStyle("
|
|
158
|
+
editor.setBorderStyle("round");
|
|
159
159
|
editor.setClosedBorderBox(true);
|
|
160
160
|
editor.setPromptGutter(undefined);
|
|
161
161
|
editor.setInputPrefix(getDefaultInputPrefix());
|
|
@@ -568,6 +568,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
568
568
|
this.ui.addChild(this.hookWidgetContainerAbove);
|
|
569
569
|
this.ui.addChild(this.editorContainer);
|
|
570
570
|
this.ui.addChild(this.hookWidgetContainerBelow);
|
|
571
|
+
this.ui.setBottomPinnedComponent(this.statusLine);
|
|
571
572
|
this.ui.setFocus(this.editor);
|
|
572
573
|
|
|
573
574
|
this.#inputController.setupKeyHandlers();
|
|
@@ -73,7 +73,7 @@ const RPC_COMMAND_SCOPE_REGISTRY: Record<RpcCommandType, BridgeCommandScope> = {
|
|
|
73
73
|
get_login_providers: "admin",
|
|
74
74
|
login: "admin",
|
|
75
75
|
negotiate_unattended: "control",
|
|
76
|
-
workflow_gate_response: "
|
|
76
|
+
workflow_gate_response: "control",
|
|
77
77
|
};
|
|
78
78
|
|
|
79
79
|
export const RPC_COMMAND_TYPES: readonly RpcCommandType[] = Object.keys(RPC_COMMAND_SCOPE_REGISTRY) as RpcCommandType[];
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/can1357/gajae-code/main/packages/coding-agent/theme-schema.json",
|
|
3
|
+
"name": "gruvbox-dark",
|
|
4
|
+
"vars": {
|
|
5
|
+
"bgHard": "#1d2021",
|
|
6
|
+
"surface": "#32302f",
|
|
7
|
+
"surfaceBright": "#504945",
|
|
8
|
+
"borderNeutral": "#504945",
|
|
9
|
+
"borderSubtle": "#3c3836",
|
|
10
|
+
"fg": "#ebdbb2",
|
|
11
|
+
"muted": "#a89984",
|
|
12
|
+
"dim": "#7c6f64",
|
|
13
|
+
"gray": "#928374",
|
|
14
|
+
"red": "#fb4934",
|
|
15
|
+
"green": "#b8bb26",
|
|
16
|
+
"yellow": "#fabd2f",
|
|
17
|
+
"blue": "#83a598",
|
|
18
|
+
"purple": "#d3869b",
|
|
19
|
+
"aqua": "#8ec07c",
|
|
20
|
+
"orange": "#fe8019",
|
|
21
|
+
"diffRemovalRed": "#cc241d"
|
|
22
|
+
},
|
|
23
|
+
"colors": {
|
|
24
|
+
"accent": "orange",
|
|
25
|
+
"border": "borderNeutral",
|
|
26
|
+
"borderAccent": "orange",
|
|
27
|
+
"borderMuted": "borderSubtle",
|
|
28
|
+
"success": "green",
|
|
29
|
+
"error": "red",
|
|
30
|
+
"warning": "yellow",
|
|
31
|
+
"muted": "muted",
|
|
32
|
+
"dim": "dim",
|
|
33
|
+
"text": "fg",
|
|
34
|
+
"thinkingText": "muted",
|
|
35
|
+
"selectedBg": "surfaceBright",
|
|
36
|
+
"userMessageBg": "surface",
|
|
37
|
+
"userMessageText": "fg",
|
|
38
|
+
"customMessageBg": "surface",
|
|
39
|
+
"customMessageText": "fg",
|
|
40
|
+
"customMessageLabel": "orange",
|
|
41
|
+
"toolPendingBg": "surface",
|
|
42
|
+
"toolSuccessBg": "#283626",
|
|
43
|
+
"toolErrorBg": "#3c2323",
|
|
44
|
+
"toolTitle": "fg",
|
|
45
|
+
"toolOutput": "muted",
|
|
46
|
+
"mdHeading": "yellow",
|
|
47
|
+
"mdLink": "blue",
|
|
48
|
+
"mdLinkUrl": "muted",
|
|
49
|
+
"mdCode": "aqua",
|
|
50
|
+
"mdCodeBlock": "fg",
|
|
51
|
+
"mdCodeBlockBorder": "borderNeutral",
|
|
52
|
+
"mdQuote": "muted",
|
|
53
|
+
"mdQuoteBorder": "borderNeutral",
|
|
54
|
+
"mdHr": "dim",
|
|
55
|
+
"mdListBullet": "orange",
|
|
56
|
+
"toolDiffAdded": "green",
|
|
57
|
+
"toolDiffRemoved": "diffRemovalRed",
|
|
58
|
+
"toolDiffContext": "muted",
|
|
59
|
+
"syntaxComment": "gray",
|
|
60
|
+
"syntaxKeyword": "red",
|
|
61
|
+
"syntaxFunction": "green",
|
|
62
|
+
"syntaxVariable": "blue",
|
|
63
|
+
"syntaxString": "green",
|
|
64
|
+
"syntaxNumber": "purple",
|
|
65
|
+
"syntaxType": "yellow",
|
|
66
|
+
"syntaxOperator": "aqua",
|
|
67
|
+
"syntaxPunctuation": "muted",
|
|
68
|
+
"thinkingOff": "dim",
|
|
69
|
+
"thinkingMinimal": "muted",
|
|
70
|
+
"thinkingLow": "aqua",
|
|
71
|
+
"thinkingMedium": "yellow",
|
|
72
|
+
"thinkingHigh": "orange",
|
|
73
|
+
"thinkingXhigh": "red",
|
|
74
|
+
"bashMode": "green",
|
|
75
|
+
"pythonMode": "yellow",
|
|
76
|
+
"statusLineBg": "bgHard",
|
|
77
|
+
"statusLineSep": "dim",
|
|
78
|
+
"statusLineModel": "orange",
|
|
79
|
+
"statusLinePath": "blue",
|
|
80
|
+
"statusLineGitClean": "green",
|
|
81
|
+
"statusLineGitDirty": "yellow",
|
|
82
|
+
"statusLineContext": "aqua",
|
|
83
|
+
"statusLineSpend": "yellow",
|
|
84
|
+
"statusLineStaged": "green",
|
|
85
|
+
"statusLineDirty": "yellow",
|
|
86
|
+
"statusLineUntracked": "diffRemovalRed",
|
|
87
|
+
"statusLineOutput": "fg",
|
|
88
|
+
"statusLineCost": "orange",
|
|
89
|
+
"statusLineSubagents": "purple"
|
|
90
|
+
},
|
|
91
|
+
"export": {
|
|
92
|
+
"pageBg": "#1d2021",
|
|
93
|
+
"cardBg": "#282828",
|
|
94
|
+
"infoBg": "#32302f"
|
|
95
|
+
},
|
|
96
|
+
"symbols": {
|
|
97
|
+
"preset": "unicode"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import blue_crab from "./blue-crab.json" with { type: "json" };
|
|
2
2
|
import claude_code from "./claude-code.json" with { type: "json" };
|
|
3
3
|
import codex from "./codex.json" with { type: "json" };
|
|
4
|
+
import gruvbox_dark from "./gruvbox-dark.json" with { type: "json" };
|
|
4
5
|
import opencode from "./opencode.json" with { type: "json" };
|
|
5
6
|
import red_claw from "./red-claw.json" with { type: "json" };
|
|
6
7
|
|
|
@@ -8,6 +9,7 @@ export const defaultThemes = {
|
|
|
8
9
|
"blue-crab": blue_crab,
|
|
9
10
|
"claude-code": claude_code,
|
|
10
11
|
codex,
|
|
12
|
+
"gruvbox-dark": gruvbox_dark,
|
|
11
13
|
opencode,
|
|
12
14
|
"red-claw": red_claw,
|
|
13
15
|
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
export interface NotificationOperatorTimerDeps {
|
|
2
|
+
now?: () => number;
|
|
3
|
+
setTimeoutImpl?: typeof setTimeout;
|
|
4
|
+
clearTimeoutImpl?: typeof clearTimeout;
|
|
5
|
+
setIntervalImpl?: typeof setInterval;
|
|
6
|
+
clearIntervalImpl?: typeof clearInterval;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface NotificationOperatorRuntimeState {
|
|
10
|
+
running: boolean;
|
|
11
|
+
stopRequested: boolean;
|
|
12
|
+
activeAbort: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface OperatorBackoffOptions {
|
|
16
|
+
initialMs: number;
|
|
17
|
+
maxMs: number;
|
|
18
|
+
factor?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type OperatorIntervalHandle = number | NodeJS.Timeout;
|
|
22
|
+
|
|
23
|
+
export class OperatorBackoffPolicy {
|
|
24
|
+
#currentMs = 0;
|
|
25
|
+
#opts: OperatorBackoffOptions;
|
|
26
|
+
|
|
27
|
+
constructor(opts: OperatorBackoffOptions) {
|
|
28
|
+
this.#opts = opts;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
next(): number {
|
|
32
|
+
this.#currentMs =
|
|
33
|
+
this.#currentMs === 0
|
|
34
|
+
? this.#opts.initialMs
|
|
35
|
+
: Math.min(this.#currentMs * (this.#opts.factor ?? 2), this.#opts.maxMs);
|
|
36
|
+
return this.#currentMs;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
reset(): void {
|
|
40
|
+
this.#currentMs = 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get currentMs(): number {
|
|
44
|
+
return this.#currentMs;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface OperatorRoute<TContext> {
|
|
49
|
+
name: string;
|
|
50
|
+
matches(event: Record<string, unknown>): boolean;
|
|
51
|
+
handle(context: TContext, event: Record<string, unknown>): Promise<void> | void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class OperatorEventRouter<TContext> {
|
|
55
|
+
readonly routes: OperatorRoute<TContext>[] = [];
|
|
56
|
+
|
|
57
|
+
add(input: OperatorRoute<TContext>): this {
|
|
58
|
+
this.routes.push(input);
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async dispatch(context: TContext, event: Record<string, unknown>): Promise<boolean> {
|
|
63
|
+
for (const route of this.routes) {
|
|
64
|
+
if (!route.matches(event)) continue;
|
|
65
|
+
await route.handle(context, event);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class NotificationOperatorRuntime {
|
|
73
|
+
#running = false;
|
|
74
|
+
#stopRequested = false;
|
|
75
|
+
#activeAbort: AbortController | undefined;
|
|
76
|
+
#intervals = new Map<string, OperatorIntervalHandle>();
|
|
77
|
+
#exclusive = new Set<string>();
|
|
78
|
+
|
|
79
|
+
#deps: NotificationOperatorTimerDeps;
|
|
80
|
+
|
|
81
|
+
constructor(deps: NotificationOperatorTimerDeps = {}) {
|
|
82
|
+
this.#deps = deps;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get state(): NotificationOperatorRuntimeState {
|
|
86
|
+
return {
|
|
87
|
+
running: this.#running,
|
|
88
|
+
stopRequested: this.#stopRequested,
|
|
89
|
+
activeAbort: this.#activeAbort !== undefined,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
start(): void {
|
|
94
|
+
this.#running = true;
|
|
95
|
+
this.#stopRequested = false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
stop(): void {
|
|
99
|
+
this.#running = false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
requestStop(): void {
|
|
103
|
+
this.#stopRequested = true;
|
|
104
|
+
this.#running = false;
|
|
105
|
+
this.#activeAbort?.abort();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get running(): boolean {
|
|
109
|
+
return this.#running;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get stopRequested(): boolean {
|
|
113
|
+
return this.#stopRequested;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
createAbortController(): AbortController {
|
|
117
|
+
this.#activeAbort = new AbortController();
|
|
118
|
+
return this.#activeAbort;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
clearAbortController(controller: AbortController): void {
|
|
122
|
+
if (this.#activeAbort === controller) this.#activeAbort = undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
startInterval(name: string, intervalMs: number, tick: () => void): void {
|
|
126
|
+
if (this.#intervals.has(name)) return;
|
|
127
|
+
const setIntervalImpl = this.#deps.setIntervalImpl ?? setInterval;
|
|
128
|
+
this.#intervals.set(name, setIntervalImpl(tick, intervalMs) as OperatorIntervalHandle);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
stopInterval(name: string): void {
|
|
132
|
+
const timer = this.#intervals.get(name);
|
|
133
|
+
if (!timer) return;
|
|
134
|
+
const clearIntervalImpl = this.#deps.clearIntervalImpl ?? clearInterval;
|
|
135
|
+
clearIntervalImpl(timer);
|
|
136
|
+
this.#intervals.delete(name);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
stopAllIntervals(): void {
|
|
140
|
+
for (const name of [...this.#intervals.keys()]) this.stopInterval(name);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async runExclusive(name: string, fn: () => Promise<void>): Promise<void> {
|
|
144
|
+
if (this.#exclusive.has(name)) return;
|
|
145
|
+
this.#exclusive.add(name);
|
|
146
|
+
try {
|
|
147
|
+
await fn();
|
|
148
|
+
} finally {
|
|
149
|
+
this.#exclusive.delete(name);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
154
|
+
return new Promise<void>(resolve => {
|
|
155
|
+
if (signal?.aborted) return resolve();
|
|
156
|
+
const timer = (this.#deps.setTimeoutImpl ?? setTimeout)(() => resolve(), ms);
|
|
157
|
+
signal?.addEventListener(
|
|
158
|
+
"abort",
|
|
159
|
+
() => {
|
|
160
|
+
(this.#deps.clearTimeoutImpl ?? clearTimeout)(timer);
|
|
161
|
+
resolve();
|
|
162
|
+
},
|
|
163
|
+
{ once: true },
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
now(): number {
|
|
169
|
+
return (this.#deps.now ?? Date.now)();
|
|
170
|
+
}
|
|
171
|
+
}
|