@gajae-code/coding-agent 0.11.10 → 0.11.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/types/cli.d.ts +8 -1
  3. package/dist/types/config/model-profile-activation.d.ts +1 -0
  4. package/dist/types/config/model-profile-contract.d.ts +2 -0
  5. package/dist/types/config/settings-schema.d.ts +57 -2
  6. package/dist/types/config/settings.d.ts +1 -0
  7. package/dist/types/extensibility/extensions/types.d.ts +21 -0
  8. package/dist/types/gjc-runtime/linux-proc.d.ts +22 -14
  9. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +60 -0
  10. package/dist/types/gjc-runtime/state-runtime.d.ts +3 -1
  11. package/dist/types/modes/acp/acp-agent.d.ts +14 -9
  12. package/dist/types/modes/components/queue-pane.d.ts +8 -0
  13. package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
  14. package/dist/types/modes/components/welcome.d.ts +1 -0
  15. package/dist/types/modes/interactive-mode.d.ts +5 -1
  16. package/dist/types/runtime/memory-domain.d.ts +9 -0
  17. package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
  18. package/dist/types/runtime/memory-guard.d.ts +39 -0
  19. package/dist/types/runtime/memory-limit.d.ts +11 -0
  20. package/dist/types/runtime-mcp/manager.d.ts +8 -0
  21. package/dist/types/sdk/acp/final-text.d.ts +19 -0
  22. package/dist/types/sdk/acp/mcp.d.ts +23 -0
  23. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  24. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  25. package/dist/types/sdk/bus/index.d.ts +3 -1
  26. package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
  27. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -4
  28. package/dist/types/sdk/bus/telegram-daemon.d.ts +23 -3
  29. package/dist/types/sdk/host/control/operations.d.ts +1 -0
  30. package/dist/types/sdk/lifecycle-session.d.ts +10 -1
  31. package/dist/types/sdk/startup-capability.d.ts +6 -0
  32. package/dist/types/session/agent-session.d.ts +9 -11
  33. package/dist/types/session/fallback-chain-controller.d.ts +18 -2
  34. package/dist/types/session/internal/managed-session-scope.d.ts +27 -0
  35. package/dist/types/session/session-manager.d.ts +34 -0
  36. package/dist/types/task/executor.d.ts +6 -0
  37. package/dist/types/task/types.d.ts +36 -0
  38. package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
  39. package/dist/types/tools/resource-gc.d.ts +19 -0
  40. package/package.json +8 -8
  41. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  42. package/scripts/run-test-manifest.ts +6 -4
  43. package/src/cli/args.ts +14 -8
  44. package/src/cli/fast-help.ts +1 -1
  45. package/src/cli.ts +48 -2
  46. package/src/commands/sdk.ts +91 -7
  47. package/src/config/model-profile-activation.ts +1 -0
  48. package/src/config/model-profile-contract.ts +13 -0
  49. package/src/config/model-profiles.ts +24 -0
  50. package/src/config/settings-schema.ts +61 -2
  51. package/src/config/settings.ts +8 -0
  52. package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -2
  53. package/src/defaults/gjc/skills/ultragoal/SKILL.md +1 -1
  54. package/src/extensibility/extensions/runner.ts +4 -0
  55. package/src/extensibility/extensions/types.ts +14 -0
  56. package/src/gjc-runtime/deep-interview-ambiguity.ts +16 -2
  57. package/src/gjc-runtime/deep-interview-state.ts +25 -3
  58. package/src/gjc-runtime/linux-proc.ts +70 -36
  59. package/src/gjc-runtime/ralplan-runtime.ts +252 -1
  60. package/src/gjc-runtime/state-runtime.ts +48 -11
  61. package/src/internal-urls/docs-index.generated.ts +4 -4
  62. package/src/internal-urls/local-protocol.ts +23 -4
  63. package/src/modes/acp/acp-agent.ts +554 -60
  64. package/src/modes/acp/acp-event-mapper.ts +93 -6
  65. package/src/modes/components/model-selector.ts +15 -1
  66. package/src/modes/components/notifications-settings-editor.ts +1 -1
  67. package/src/modes/components/queue-pane.ts +55 -9
  68. package/src/modes/components/queued-message-selector.ts +53 -10
  69. package/src/modes/components/tool-status-header.ts +1 -4
  70. package/src/modes/components/welcome.ts +18 -3
  71. package/src/modes/controllers/command-controller.ts +4 -0
  72. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  73. package/src/modes/controllers/input-controller.ts +44 -3
  74. package/src/modes/interactive-mode.ts +65 -26
  75. package/src/modes/runtime-init.ts +1 -0
  76. package/src/prompts/agents/executor.md +1 -1
  77. package/src/runtime/memory-domain.ts +66 -0
  78. package/src/runtime/memory-guard-contract.ts +73 -0
  79. package/src/runtime/memory-guard.ts +292 -0
  80. package/src/runtime/memory-limit.ts +50 -0
  81. package/src/runtime-mcp/manager.ts +26 -6
  82. package/src/sdk/acp/adapter.ts +8 -1
  83. package/src/sdk/acp/final-text.ts +40 -0
  84. package/src/sdk/acp/mcp.ts +26 -0
  85. package/src/sdk/broker/lifecycle.ts +82 -1
  86. package/src/sdk/bus/chat-daemon-cli.ts +2 -2
  87. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  88. package/src/sdk/bus/config.ts +1 -1
  89. package/src/sdk/bus/index.ts +162 -22
  90. package/src/sdk/bus/rate-limit-pool.ts +8 -0
  91. package/src/sdk/bus/telegram-daemon-contract.ts +6 -4
  92. package/src/sdk/bus/telegram-daemon.ts +531 -53
  93. package/src/sdk/host/control/dispatch.ts +2 -0
  94. package/src/sdk/host/control/operations.ts +1 -0
  95. package/src/sdk/host/host.ts +2 -2
  96. package/src/sdk/lifecycle-session.ts +19 -3
  97. package/src/sdk/protocol/operation-inventory.generated.json +47 -0
  98. package/src/sdk/protocol/operation-registry.ts +1 -0
  99. package/src/sdk/session.ts +12 -1
  100. package/src/sdk/startup-capability.ts +6 -0
  101. package/src/session/agent-session.ts +256 -142
  102. package/src/session/fallback-chain-controller.ts +47 -8
  103. package/src/session/internal/managed-session-scope.ts +40 -10
  104. package/src/session/internal/native-publish-outcome.ts +8 -3
  105. package/src/session/session-manager.ts +608 -25
  106. package/src/task/executor.ts +11 -3
  107. package/src/task/index.ts +2 -0
  108. package/src/task/types.ts +22 -11
  109. package/src/task/ultragoal-redteam-activation.ts +75 -0
  110. package/src/tools/resource-gc.ts +496 -120
  111. package/src/workflow/workflow-intent-diff.ts +4 -1
@@ -18,6 +18,7 @@ import chalk from "chalk";
18
18
  import { AsyncJobManager } from "../async";
19
19
  import {
20
20
  type AppKeybinding,
21
+ defaultMessageQueueKeysForPlatform,
21
22
  formatKeyHint,
22
23
  formatKeyHints,
23
24
  KeybindingsManager,
@@ -47,6 +48,7 @@ import type { SessionContext, SessionManager } from "../session/session-manager"
47
48
  import { getRecentSessions, getSessionMessageEntryId } from "../session/session-manager";
48
49
  import type { LspStartupServerInfo } from "../tools";
49
50
  import { formatPhaseDisplayName } from "../tools/todo-write";
51
+ import { copyToClipboard } from "../utils/clipboard";
50
52
  import type { EventBus } from "../utils/event-bus";
51
53
  import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
52
54
  import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
@@ -113,16 +115,60 @@ import {
113
115
  import type { ParsedIrcMessage } from "./utils/irc-message";
114
116
  import { addChatChild, prepareTranscriptRebuild, UiHelpers } from "./utils/ui-helpers";
115
117
 
116
- export function getDefaultComposerPlaceholder(context: KeyDisplayContext = { platform: process.platform }): string {
118
+ function buildComposerPlaceholder(
119
+ keybindings: Pick<KeybindingsManager, "getDisplayString">,
120
+ context: KeyDisplayContext,
121
+ options: { readonly busy: boolean; readonly busyPromptMode: "steer" | "queue" },
122
+ ): string {
123
+ const parts: string[] = [];
124
+ const submitKey = options.busy ? keybindings.getDisplayString("tui.input.submit", context) : "";
125
+ if (submitKey) {
126
+ const submitAction = options.busyPromptMode === "steer" ? "Steer" : "Queue";
127
+ parts.push(`${submitKey}: ${submitAction}`);
128
+ }
129
+
130
+ const queueKey = keybindings.getDisplayString("app.message.queue", context);
131
+ const submitQueues = options.busy && options.busyPromptMode === "queue" && submitKey;
132
+ if (queueKey && !submitQueues) parts.push(`${queueKey}: ${options.busy ? "Queue" : "Queue (busy)"}`);
133
+
134
+ const actionHints = [
135
+ ["app.thinking.cycle", "Thinking"],
136
+ ["app.model.select", "Model"],
137
+ ["app.history.search", "History"],
138
+ ] as const;
139
+ for (const [id, label] of actionHints) {
140
+ const key = keybindings.getDisplayString(id, context);
141
+ if (key) parts.push(`${key}: ${label}`);
142
+ }
143
+
117
144
  const newlineKeys = context.platform === "win32" ? ["alt+enter", "ctrl+j"] : ["shift+enter", "ctrl+j"];
118
- return `Type your message... ${formatKeyHints(newlineKeys, context)}: New line · ${formatKeyHint(
119
- "ctrl+c",
120
- context,
121
- )}: Clear · ${formatKeyHint("ctrl+r", context)}: Search history · ${formatKeyHint("shift+tab", context)}: Reasoning`;
145
+ parts.push(`${formatKeyHints(newlineKeys, context)}: New line`, `${formatKeyHint("ctrl+c", context)}: Clear`);
146
+ return `Type your message... ${parts.join(" · ")}`;
147
+ }
148
+
149
+ export function getDefaultComposerPlaceholder(
150
+ context: KeyDisplayContext = { platform: process.platform },
151
+ keybindings?: Pick<KeybindingsManager, "getDisplayString">,
152
+ ): string {
153
+ const effectiveKeybindings =
154
+ keybindings ??
155
+ KeybindingsManager.inMemory({
156
+ "app.message.queue": defaultMessageQueueKeysForPlatform(context.platform),
157
+ });
158
+ return buildComposerPlaceholder(effectiveKeybindings, context, { busy: false, busyPromptMode: "steer" });
122
159
  }
123
160
 
124
161
  export const DEFAULT_COMPOSER_PLACEHOLDER = getDefaultComposerPlaceholder();
162
+
163
+ export function getComposerPlaceholder(
164
+ keybindings: Pick<KeybindingsManager, "getDisplayString">,
165
+ context: KeyDisplayContext,
166
+ options: { readonly busy: boolean; readonly busyPromptMode: "steer" | "queue" },
167
+ ): string {
168
+ return buildComposerPlaceholder(keybindings, context, options);
169
+ }
125
170
  const WELCOME_RESERVED_CONTAINER_CHILD_LIMIT = 8;
171
+ const COMPOSER_RIGHT_GUTTER_WIDTH = 1;
126
172
 
127
173
  const IRC_SIDEBAR_TOGGLE_SHADOWING_ACTIONS: readonly AppKeybinding[] = [
128
174
  "app.plan.toggle",
@@ -169,7 +215,7 @@ function configureDefaultComposerChrome(editor: CustomEditor): void {
169
215
  editor.setInputPrefix(getDefaultInputPrefix());
170
216
  editor.setPlaceholder(getDefaultComposerPlaceholder());
171
217
  editor.setPaddingX(1);
172
- editor.setRightGutterWidth(1);
218
+ editor.setRightGutterWidth(COMPOSER_RIGHT_GUTTER_WIDTH);
173
219
  editor.setTopBorder(undefined);
174
220
  }
175
221
 
@@ -510,6 +556,14 @@ export class InteractiveMode implements InteractiveModeContext {
510
556
 
511
557
  this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"), {
512
558
  enableMouse: settings.get("mouse.enabled"),
559
+ copySelection: async text => {
560
+ try {
561
+ await copyToClipboard(text);
562
+ this.showStatus("Selection copied to clipboard");
563
+ } catch (error) {
564
+ this.showError(`Failed to copy selection: ${error instanceof Error ? error.message : String(error)}`);
565
+ }
566
+ },
513
567
  });
514
568
  this.ui.setClearOnShrink(settings.get("clearOnShrink"));
515
569
  this.chatContainer = new Container();
@@ -660,6 +714,7 @@ export class InteractiveMode implements InteractiveModeContext {
660
714
  getViewportRows: () => this.ui.terminal.rows,
661
715
  getReservedBottomRows: getWelcomeReservedBottomRows,
662
716
  changelogMarkdown: this.#changelogMarkdown,
717
+ rightGutterWidth: COMPOSER_RIGHT_GUTTER_WIDTH,
663
718
  collapseChangelog: settings.get("collapseChangelog"),
664
719
  keyDisplayContext: this.#keyDisplayContext,
665
720
  },
@@ -1056,27 +1111,11 @@ export class InteractiveMode implements InteractiveModeContext {
1056
1111
  return this.session.isStreaming || this.session.isCompacting;
1057
1112
  }
1058
1113
 
1059
- #getFirstKeyForAction(action: AppKeybinding): string | undefined {
1060
- return this.keybindings.getKeys(action)[0];
1061
- }
1062
-
1063
- #getMessageQueueShortcut(): string | undefined {
1064
- const preferredAction: AppKeybinding =
1065
- process.platform === "darwin" ? "app.message.followUp" : "app.message.queue";
1066
- const fallbackAction: AppKeybinding =
1067
- process.platform === "darwin" ? "app.message.queue" : "app.message.followUp";
1068
- return this.#getFirstKeyForAction(preferredAction) ?? this.#getFirstKeyForAction(fallbackAction);
1069
- }
1070
-
1071
1114
  #getComposerPlaceholder(): string {
1072
- const defaultPlaceholder = getDefaultComposerPlaceholder(this.#keyDisplayContext);
1073
- if (!this.#isPromptDeliveryBusy()) return defaultPlaceholder;
1074
- const submitAction = this.settings.get("busyPromptMode") === "steer" ? "Steer" : "Queue";
1075
- const submitKey = this.keybindings.getDisplayString("tui.input.submit", this.#keyDisplayContext);
1076
- const parts = submitKey ? [`${submitKey}: ${submitAction}`] : [];
1077
- const queueKey = this.#getMessageQueueShortcut();
1078
- if (queueKey) parts.push(`${formatKeyHint(queueKey, this.#keyDisplayContext)}: Queue`);
1079
- return parts.length > 0 ? `${defaultPlaceholder} · ${parts.join(" · ")}` : defaultPlaceholder;
1115
+ return getComposerPlaceholder(this.keybindings, this.#keyDisplayContext, {
1116
+ busy: this.#isPromptDeliveryBusy(),
1117
+ busyPromptMode: this.settings.get("busyPromptMode"),
1118
+ });
1080
1119
  }
1081
1120
 
1082
1121
  #getWelcomeReservedRows(width: number): number {
@@ -115,6 +115,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi
115
115
  compact: instructionsOrOptions => runExtensionCompact(session, instructionsOrOptions),
116
116
  clearContext: () => session.clearContext(),
117
117
  cycleModel: () => session.cycleModel(),
118
+ setModelProfile: name => session.activateModelProfileForControl(name),
118
119
  cycleThinkingLevel: () => session.cycleThinkingLevel(),
119
120
  setQueueMode: (kind, mode) => {
120
121
  if (kind === "steering" && (mode === "all" || mode === "one-at-a-time")) {
@@ -34,7 +34,7 @@ Explore just enough context, implement the smallest correct change, and leave co
34
34
  {{#if ultragoalRedTeam}}
35
35
 
36
36
  <ultragoal_red_team_mode>
37
- This mode activates only when the assignment explicitly labels Executor as Ultragoal completion QA/red-team or asks for `executorQa` red-team evidence. Otherwise, preserve ordinary Executor behavior.
37
+ This mode activates only when the task sets typed `executionMode: "ultragoal-red-team"`, or — as a compatibility fallback — when the assignment text explicitly labels Executor as Ultragoal completion QA/red-team or asks for `executorQa` red-team evidence (not a bare `executorQa` field-name mention). Prefer the typed flag. Otherwise, preserve ordinary Executor behavior.
38
38
 
39
39
  When active:
40
40
  - Follow the exact `executorQa` contract provided in the assignment (matrix shape, row fields, artifact/replay rules); the runtime validates it strictly. If the assignment omits the contract, read the ultragoal SKILL's executor QA section before producing evidence.
@@ -0,0 +1,66 @@
1
+ import type {
2
+ MemoryGuardDomainSnapshot,
3
+ MemoryGuardWorkerAccounting,
4
+ MemoryGuardWorkerSample,
5
+ } from "./memory-guard-contract";
6
+
7
+ export interface MemoryGuardDomainInput {
8
+ effectiveLimitBytes: number;
9
+ totalUsageBytes: number;
10
+ parentBytes: number;
11
+ parentReserveBytes: number;
12
+ workers: readonly MemoryGuardWorkerSample[];
13
+ }
14
+
15
+ function assertNonNegativeSafeInteger(name: string, value: number): number {
16
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid_${name}`);
17
+ return value;
18
+ }
19
+
20
+ function sumWorkerBytes(workers: readonly MemoryGuardWorkerSample[]): number {
21
+ let total = 0;
22
+ for (const worker of workers) {
23
+ total += assertNonNegativeSafeInteger(`worker_bytes:${worker.workerId}`, worker.bytes);
24
+ if (!Number.isSafeInteger(total)) throw new Error("invalid_worker_total_bytes");
25
+ }
26
+ return total;
27
+ }
28
+
29
+ export function computeMemoryGuardDomain(input: MemoryGuardDomainInput): MemoryGuardDomainSnapshot {
30
+ const effectiveLimitBytes = assertNonNegativeSafeInteger("effective_limit_bytes", input.effectiveLimitBytes);
31
+ const totalUsageBytes = assertNonNegativeSafeInteger("total_usage_bytes", input.totalUsageBytes);
32
+ const parentBytes = assertNonNegativeSafeInteger("parent_bytes", input.parentBytes);
33
+ const parentReserveBytes = assertNonNegativeSafeInteger("parent_reserve_bytes", input.parentReserveBytes);
34
+ const totalWorkerBytes = sumWorkerBytes(input.workers);
35
+ const acceptedWorkers = input.workers.filter(worker => worker.accepted !== false);
36
+ const acceptedWorkerBytes = sumWorkerBytes(acceptedWorkers);
37
+ const acceptedWorkerCount = acceptedWorkers.length;
38
+ const unmanagedBytes = Math.max(0, totalUsageBytes - parentBytes - acceptedWorkerBytes);
39
+ const headroomBytes = Math.max(0, effectiveLimitBytes - totalUsageBytes);
40
+ const workerBudgetBytes = Math.max(0, effectiveLimitBytes - unmanagedBytes - parentReserveBytes);
41
+ const perWorkerAllowanceBytes = acceptedWorkerCount === 0 ? 0 : workerBudgetBytes / acceptedWorkerCount;
42
+ const workers: MemoryGuardWorkerAccounting[] = input.workers.map(worker => {
43
+ const accepted = worker.accepted !== false;
44
+ return {
45
+ workerId: worker.workerId,
46
+ bytes: worker.bytes,
47
+ accepted,
48
+ allowanceBytes: accepted ? perWorkerAllowanceBytes : 0,
49
+ excessBytes: accepted ? Math.max(0, worker.bytes - perWorkerAllowanceBytes) : 0,
50
+ };
51
+ });
52
+ return {
53
+ effectiveLimitBytes,
54
+ totalUsageBytes,
55
+ parentBytes,
56
+ parentReserveBytes,
57
+ totalWorkerBytes,
58
+ acceptedWorkerCount,
59
+ unmanagedBytes,
60
+ headroomBytes,
61
+ workerBudgetBytes,
62
+ perWorkerAllowanceBytes,
63
+ hostExcessBytes: Math.max(0, parentBytes - parentReserveBytes),
64
+ workers,
65
+ };
66
+ }
@@ -0,0 +1,73 @@
1
+ export interface MemoryGuardPolicy {
2
+ enabled: boolean;
3
+ checkIntervalMs: number;
4
+ gcThresholdRatio: number;
5
+ restartThresholdRatio: number;
6
+ restartThresholdWindowMs: number;
7
+ cooldownMs: number;
8
+ parentReserveBytes: number;
9
+ policyLimitBytes: number | null;
10
+ }
11
+
12
+ export interface MemoryGuardWorkerSample {
13
+ workerId: string;
14
+ bytes: number;
15
+ accepted?: boolean;
16
+ }
17
+
18
+ export interface MemoryGuardWorkerAccounting {
19
+ workerId: string;
20
+ bytes: number;
21
+ accepted: boolean;
22
+ allowanceBytes: number;
23
+ excessBytes: number;
24
+ }
25
+
26
+ export interface MemoryGuardDomainSnapshot {
27
+ effectiveLimitBytes: number;
28
+ totalUsageBytes: number;
29
+ parentBytes: number;
30
+ parentReserveBytes: number;
31
+ totalWorkerBytes: number;
32
+ acceptedWorkerCount: number;
33
+ unmanagedBytes: number;
34
+ headroomBytes: number;
35
+ workerBudgetBytes: number;
36
+ perWorkerAllowanceBytes: number;
37
+ hostExcessBytes: number;
38
+ workers: MemoryGuardWorkerAccounting[];
39
+ }
40
+
41
+ export interface MemoryGuardHostTarget {
42
+ kind: "host";
43
+ excessBytes: number;
44
+ }
45
+
46
+ export interface MemoryGuardWorkerTarget {
47
+ kind: "worker";
48
+ workerId: string;
49
+ excessBytes: number;
50
+ }
51
+
52
+ export type MemoryGuardActionTarget = MemoryGuardHostTarget | MemoryGuardWorkerTarget;
53
+
54
+ export type MemoryGuardNoopReason =
55
+ | "memory_guard_action_noop_unmanaged_or_within_allowance"
56
+ | "memory_guard_action_noop_unsupported";
57
+
58
+ export type MemoryGuardDecision =
59
+ | { kind: "execute"; target: MemoryGuardActionTarget }
60
+ | { kind: "noop"; reason: MemoryGuardNoopReason }
61
+ | { kind: "revalidated_out"; reason: "memory_guard_action_revalidated_out" };
62
+
63
+ export interface MemoryGuardSchedulerState {
64
+ timerActive: boolean;
65
+ registrationCount: number;
66
+ inProgress: boolean;
67
+ generation: number;
68
+ pendingDeadline: number | null;
69
+ pendingOwner: { generation: number; token: number } | null;
70
+ deferredDeadline: number | null;
71
+ deferredGeneration: number | null;
72
+ activeGeneration: number | null;
73
+ }
@@ -0,0 +1,292 @@
1
+ import type { Settings } from "../config/settings";
2
+ import type {
3
+ MemoryGuardActionTarget,
4
+ MemoryGuardDecision,
5
+ MemoryGuardDomainSnapshot,
6
+ MemoryGuardPolicy,
7
+ MemoryGuardSchedulerState,
8
+ } from "./memory-guard-contract";
9
+
10
+ const BYTES_PER_MB = 1024 * 1024;
11
+ const DEFAULT_CHECK_INTERVAL_MS = 30_000;
12
+
13
+ type ScheduleOwner = { generation: number; token: number };
14
+ type DeferredSchedule = { generation: number; deadline: number };
15
+ type WorkOwner = { generation: number; source: "timer" | "external" };
16
+
17
+ export interface MemoryGuardHostRegistration {
18
+ ownerId: string;
19
+ intervalMs: number;
20
+ }
21
+
22
+ export interface MemoryGuardHostOptions {
23
+ run: () => Promise<void>;
24
+ logDebug?: (message: string, meta?: Record<string, unknown>) => void;
25
+ schedulerNow?: () => number;
26
+ }
27
+
28
+ function normalizePositiveIntervalMs(intervalMs: number): number {
29
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) throw new Error("invalid_interval_ms");
30
+ return intervalMs;
31
+ }
32
+
33
+ function toRatio(percent: number): number {
34
+ return percent / 100;
35
+ }
36
+
37
+ function mbToBytes(value: number): number {
38
+ return Math.round(value * BYTES_PER_MB);
39
+ }
40
+
41
+ function sortTargets(left: MemoryGuardActionTarget, right: MemoryGuardActionTarget): number {
42
+ if (left.excessBytes !== right.excessBytes) return right.excessBytes - left.excessBytes;
43
+ if (left.kind !== right.kind) return left.kind === "host" ? -1 : 1;
44
+ if (left.kind === "worker" && right.kind === "worker") return left.workerId.localeCompare(right.workerId);
45
+ return 0;
46
+ }
47
+
48
+ export function resolveMemoryGuardPolicy(settings: Settings): MemoryGuardPolicy {
49
+ const configuredPolicyLimitMb = settings.get("memoryGuard.policyLimitMb");
50
+ return {
51
+ enabled: settings.get("memoryGuard.enabled"),
52
+ checkIntervalMs: settings.get("memoryGuard.checkIntervalMs"),
53
+ gcThresholdRatio: toRatio(settings.get("memoryGuard.gcThresholdPercent")),
54
+ restartThresholdRatio: toRatio(settings.get("memoryGuard.restartThresholdPercent")),
55
+ restartThresholdWindowMs: settings.get("memoryGuard.restartThresholdWindowMs"),
56
+ cooldownMs: settings.get("memoryGuard.cooldownMs"),
57
+ parentReserveBytes: mbToBytes(settings.get("memoryGuard.parentReserveMb")),
58
+ policyLimitBytes: configuredPolicyLimitMb > 0 ? mbToBytes(configuredPolicyLimitMb) : null,
59
+ };
60
+ }
61
+
62
+ export function chooseMemoryGuardAction(input: {
63
+ domain: MemoryGuardDomainSnapshot;
64
+ hostSupported: boolean;
65
+ workerSupported: (workerId: string) => boolean;
66
+ }): MemoryGuardDecision {
67
+ const candidates: MemoryGuardActionTarget[] = [];
68
+ if (input.hostSupported && input.domain.hostExcessBytes > 0) {
69
+ candidates.push({ kind: "host", excessBytes: input.domain.hostExcessBytes });
70
+ }
71
+ for (const worker of input.domain.workers) {
72
+ if (!worker.accepted || worker.excessBytes <= 0 || !input.workerSupported(worker.workerId)) continue;
73
+ candidates.push({ kind: "worker", workerId: worker.workerId, excessBytes: worker.excessBytes });
74
+ }
75
+ if (candidates.length > 0) {
76
+ candidates.sort(sortTargets);
77
+ return { kind: "execute", target: candidates[0]! };
78
+ }
79
+ const unsupportedExcessExists =
80
+ input.domain.hostExcessBytes > 0 ||
81
+ input.domain.workers.some(worker => worker.accepted && worker.excessBytes > 0);
82
+ if (unsupportedExcessExists) return { kind: "noop", reason: "memory_guard_action_noop_unsupported" };
83
+ return { kind: "noop", reason: "memory_guard_action_noop_unmanaged_or_within_allowance" };
84
+ }
85
+
86
+ export function revalidateMemoryGuardAction(
87
+ previous: Extract<MemoryGuardDecision, { kind: "execute" }>,
88
+ revalidated: MemoryGuardDecision,
89
+ ): MemoryGuardDecision {
90
+ if (revalidated.kind !== "execute")
91
+ return { kind: "revalidated_out", reason: "memory_guard_action_revalidated_out" };
92
+ if (previous.target.kind !== revalidated.target.kind)
93
+ return { kind: "revalidated_out", reason: "memory_guard_action_revalidated_out" };
94
+ if (
95
+ previous.target.kind === "worker" &&
96
+ revalidated.target.kind === "worker" &&
97
+ previous.target.workerId !== revalidated.target.workerId
98
+ ) {
99
+ return { kind: "revalidated_out", reason: "memory_guard_action_revalidated_out" };
100
+ }
101
+ return revalidated;
102
+ }
103
+
104
+ export class MemoryGuardHost {
105
+ #run: () => Promise<void>;
106
+ #logDebug: (message: string, meta?: Record<string, unknown>) => void;
107
+ #defaultSchedulerNow: () => number;
108
+ #schedulerNow: () => number;
109
+ #registrations = new Map<string, { intervalMs: number; nextDueMs: number }>();
110
+ #pendingTimer: NodeJS.Timeout | null = null;
111
+ #pendingDeadline: number | null = null;
112
+ #pendingOwner: ScheduleOwner | null = null;
113
+ #deferredSchedule: DeferredSchedule | null = null;
114
+ #inProgressOwner: WorkOwner | null = null;
115
+ #stopped = false;
116
+ #generation = 0;
117
+ #nextTimerToken = 0;
118
+
119
+ constructor(options: MemoryGuardHostOptions) {
120
+ this.#run = options.run;
121
+ this.#logDebug = options.logDebug ?? (() => undefined);
122
+ this.#defaultSchedulerNow = options.schedulerNow ?? (() => performance.now());
123
+ this.#schedulerNow = this.#defaultSchedulerNow;
124
+ }
125
+
126
+ register(registration: MemoryGuardHostRegistration): () => void {
127
+ const intervalMs = normalizePositiveIntervalMs(registration.intervalMs);
128
+ const now = this.#schedulerNow();
129
+ const existing = this.#registrations.get(registration.ownerId);
130
+ const isNewRegistration = !existing;
131
+ const nextDueMs = existing ? Math.min(existing.nextDueMs, now + intervalMs) : now + intervalMs;
132
+ this.#registrations.set(registration.ownerId, { intervalMs, nextDueMs });
133
+ this.#stopped = false;
134
+ if (isNewRegistration) {
135
+ const deadline = nextDueMs;
136
+ if (
137
+ this.#inProgressOwner?.generation === this.#generation &&
138
+ (this.#pendingDeadline === null || this.#pendingDeadline <= deadline)
139
+ ) {
140
+ this.#deferSchedule(this.#generation, deadline);
141
+ } else {
142
+ this.#requestSchedule(deadline);
143
+ }
144
+ }
145
+ let unregistered = false;
146
+ return () => {
147
+ if (unregistered) return;
148
+ unregistered = true;
149
+ this.#registrations.delete(registration.ownerId);
150
+ if (this.#registrations.size === 0) this.#stop();
151
+ };
152
+ }
153
+
154
+ updateInterval(ownerId: string, intervalMs: number): void {
155
+ const existing = this.#registrations.get(ownerId);
156
+ if (!existing) return;
157
+ const normalized = normalizePositiveIntervalMs(intervalMs);
158
+ if (existing.intervalMs === normalized) return;
159
+ const now = this.#schedulerNow();
160
+ const nextDueMs = now + normalized;
161
+ this.#registrations.set(ownerId, { intervalMs: normalized, nextDueMs });
162
+ if (this.#inProgressOwner) return;
163
+ const nextDeadline = this.#nextDeadlineMs();
164
+ if (this.#pendingDeadline !== null && nextDeadline > this.#pendingDeadline) {
165
+ this.#clearPendingSchedule();
166
+ }
167
+ this.#requestSchedule(nextDeadline);
168
+ }
169
+
170
+ async runTick(generation = this.#generation, source: WorkOwner["source"] = "external"): Promise<void> {
171
+ if (this.#inProgressOwner || this.#registrations.size === 0) return;
172
+ const owner: WorkOwner = { generation, source };
173
+ this.#inProgressOwner = owner;
174
+ try {
175
+ await this.#run();
176
+ } catch (error) {
177
+ this.#logDebug("memory guard tick failed", { error: error instanceof Error ? error.message : String(error) });
178
+ } finally {
179
+ if (this.#inProgressOwner === owner) this.#inProgressOwner = null;
180
+ this.#advanceDueTimes();
181
+ this.#reconcileCurrentSchedule();
182
+ }
183
+ }
184
+
185
+ async runTimerCallbackForTest(owner: { generation: number; token: number }, deadline: number): Promise<void> {
186
+ await this.#handleTimerCallback(owner, deadline);
187
+ }
188
+
189
+ setSchedulerNowForTest(now: () => number): void {
190
+ this.#schedulerNow = now;
191
+ }
192
+
193
+ getStateForTest(): MemoryGuardSchedulerState {
194
+ return {
195
+ timerActive: this.#pendingTimer !== null,
196
+ registrationCount: this.#registrations.size,
197
+ inProgress: this.#inProgressOwner !== null,
198
+ generation: this.#generation,
199
+ pendingDeadline: this.#pendingDeadline,
200
+ pendingOwner: this.#pendingOwner ? { ...this.#pendingOwner } : null,
201
+ deferredDeadline: this.#deferredSchedule?.deadline ?? null,
202
+ deferredGeneration: this.#deferredSchedule?.generation ?? null,
203
+ activeGeneration: this.#inProgressOwner?.generation ?? null,
204
+ };
205
+ }
206
+
207
+ resetForTest(): void {
208
+ this.#stop();
209
+ this.#registrations.clear();
210
+ this.#inProgressOwner = null;
211
+ this.#deferredSchedule = null;
212
+ this.#nextTimerToken = 0;
213
+ this.#schedulerNow = this.#defaultSchedulerNow;
214
+ }
215
+
216
+ #nextDeadlineMs(): number {
217
+ let minDue = Number.POSITIVE_INFINITY;
218
+ for (const reg of this.#registrations.values()) {
219
+ minDue = Math.min(minDue, reg.nextDueMs);
220
+ }
221
+ return Number.isFinite(minDue) ? minDue : this.#schedulerNow() + DEFAULT_CHECK_INTERVAL_MS;
222
+ }
223
+
224
+ #advanceDueTimes(): void {
225
+ const now = this.#schedulerNow();
226
+ for (const reg of this.#registrations.values()) {
227
+ if (reg.nextDueMs <= now) {
228
+ reg.nextDueMs = now + reg.intervalMs;
229
+ }
230
+ }
231
+ }
232
+
233
+ #clearPendingSchedule(): void {
234
+ if (this.#pendingTimer) clearTimeout(this.#pendingTimer);
235
+ this.#pendingTimer = null;
236
+ this.#pendingDeadline = null;
237
+ this.#pendingOwner = null;
238
+ }
239
+
240
+ #requestSchedule(deadline: number): void {
241
+ if (this.#stopped || this.#registrations.size === 0) return;
242
+ if (this.#pendingDeadline !== null && this.#pendingDeadline <= deadline) return;
243
+ this.#clearPendingSchedule();
244
+ const owner = { generation: this.#generation, token: ++this.#nextTimerToken };
245
+ this.#pendingDeadline = deadline;
246
+ this.#pendingOwner = owner;
247
+ this.#pendingTimer = setTimeout(
248
+ () => {
249
+ void this.#handleTimerCallback(owner, deadline);
250
+ },
251
+ Math.max(0, deadline - this.#schedulerNow()),
252
+ );
253
+ this.#pendingTimer.unref?.();
254
+ }
255
+
256
+ #deferSchedule(generation: number, deadline: number): void {
257
+ if (generation !== this.#generation) return;
258
+ if (this.#deferredSchedule?.generation === generation) {
259
+ this.#deferredSchedule.deadline = Math.min(this.#deferredSchedule.deadline, deadline);
260
+ return;
261
+ }
262
+ this.#deferredSchedule = { generation, deadline };
263
+ }
264
+
265
+ async #handleTimerCallback(owner: ScheduleOwner, deadline: number): Promise<void> {
266
+ if (this.#pendingOwner?.generation !== owner.generation || this.#pendingOwner.token !== owner.token) return;
267
+ this.#pendingTimer = null;
268
+ this.#pendingDeadline = null;
269
+ this.#pendingOwner = null;
270
+ if (this.#inProgressOwner) {
271
+ this.#deferSchedule(owner.generation, deadline);
272
+ return;
273
+ }
274
+ await this.runTick(owner.generation, "timer");
275
+ }
276
+
277
+ #reconcileCurrentSchedule(): void {
278
+ if (this.#stopped || this.#registrations.size === 0 || this.#inProgressOwner) return;
279
+ const normalDeadline = this.#nextDeadlineMs();
280
+ const deferredDeadline =
281
+ this.#deferredSchedule?.generation === this.#generation ? this.#deferredSchedule.deadline : null;
282
+ if (deferredDeadline !== null) this.#deferredSchedule = null;
283
+ this.#requestSchedule(deferredDeadline === null ? normalDeadline : Math.min(deferredDeadline, normalDeadline));
284
+ }
285
+
286
+ #stop(): void {
287
+ this.#stopped = true;
288
+ this.#generation++;
289
+ this.#clearPendingSchedule();
290
+ this.#deferredSchedule = null;
291
+ }
292
+ }
@@ -0,0 +1,50 @@
1
+ export interface EffectiveMemoryLimitInput {
2
+ hardCapBytes?: number | null;
3
+ policyLimitBytes?: number | null;
4
+ }
5
+
6
+ export interface EffectiveMemoryLimit {
7
+ hardCapBytes: number | null;
8
+ policyLimitBytes: number | null;
9
+ effectiveBytes: number | null;
10
+ source: "none" | "hard_cap" | "policy_limit" | "hard_cap_and_policy_limit";
11
+ }
12
+
13
+ function normalizePositiveByteCount(value: number | null | undefined): number | null {
14
+ return Number.isSafeInteger(value) && typeof value === "number" && value > 0 ? value : null;
15
+ }
16
+
17
+ export function resolveEffectiveMemoryLimit(input: EffectiveMemoryLimitInput): EffectiveMemoryLimit {
18
+ const hardCapBytes = normalizePositiveByteCount(input.hardCapBytes);
19
+ const policyLimitBytes = normalizePositiveByteCount(input.policyLimitBytes);
20
+ if (hardCapBytes !== null && policyLimitBytes !== null) {
21
+ return {
22
+ hardCapBytes,
23
+ policyLimitBytes,
24
+ effectiveBytes: Math.min(hardCapBytes, policyLimitBytes),
25
+ source: "hard_cap_and_policy_limit",
26
+ };
27
+ }
28
+ if (hardCapBytes !== null) {
29
+ return {
30
+ hardCapBytes,
31
+ policyLimitBytes: null,
32
+ effectiveBytes: hardCapBytes,
33
+ source: "hard_cap",
34
+ };
35
+ }
36
+ if (policyLimitBytes !== null) {
37
+ return {
38
+ hardCapBytes: null,
39
+ policyLimitBytes,
40
+ effectiveBytes: policyLimitBytes,
41
+ source: "policy_limit",
42
+ };
43
+ }
44
+ return {
45
+ hardCapBytes: null,
46
+ policyLimitBytes: null,
47
+ effectiveBytes: null,
48
+ source: "none",
49
+ };
50
+ }