@capekai/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +12 -0
  2. package/package.json +105 -0
  3. package/src/adapters/ai-sdk.ts +84 -0
  4. package/src/compaction/contracts.ts +82 -0
  5. package/src/compaction/executor.ts +161 -0
  6. package/src/compaction/policy.ts +318 -0
  7. package/src/compaction/recovery.ts +139 -0
  8. package/src/compaction/task.ts +540 -0
  9. package/src/configuration/contracts.ts +58 -0
  10. package/src/configuration/defaults.ts +27 -0
  11. package/src/configuration/runtime.ts +42 -0
  12. package/src/configuration/single-model.ts +75 -0
  13. package/src/context/assembler.ts +112 -0
  14. package/src/context/index.ts +2 -0
  15. package/src/context/sources.ts +119 -0
  16. package/src/context/workspace.ts +63 -0
  17. package/src/core/agent.ts +401 -0
  18. package/src/core/build-tools.ts +139 -0
  19. package/src/core/chat-handler.ts +858 -0
  20. package/src/core/error-handling.ts +18 -0
  21. package/src/core/fork.ts +103 -0
  22. package/src/core/interrupt.ts +192 -0
  23. package/src/core/message-utils.ts +261 -0
  24. package/src/core/model-utils.ts +149 -0
  25. package/src/core/part-utils.ts +88 -0
  26. package/src/core/provider-utils.ts +67 -0
  27. package/src/core/revert.ts +46 -0
  28. package/src/core/step-handlers.ts +157 -0
  29. package/src/core/stream/finalization.ts +65 -0
  30. package/src/core/stream/stream-config.ts +82 -0
  31. package/src/core/stream-handlers.ts +242 -0
  32. package/src/core/structured-output.ts +68 -0
  33. package/src/core/tool-builders/agent-tools.ts +71 -0
  34. package/src/core/tool-builders/external-tools.ts +179 -0
  35. package/src/core/tool-builders/types.ts +16 -0
  36. package/src/core/tool-builders/workspace-tools.ts +293 -0
  37. package/src/core/tool-capabilities.ts +65 -0
  38. package/src/goals/evaluator.ts +171 -0
  39. package/src/goals/index.ts +3 -0
  40. package/src/goals/loop.ts +167 -0
  41. package/src/goals/service.ts +39 -0
  42. package/src/index.ts +10 -0
  43. package/src/internal/ask-authority.ts +29 -0
  44. package/src/internal/composition.ts +44 -0
  45. package/src/internal/configuration.ts +22 -0
  46. package/src/internal/execution.ts +108 -0
  47. package/src/internal/hosts.ts +64 -0
  48. package/src/internal/plugins.ts +71 -0
  49. package/src/internal/providers.ts +32 -0
  50. package/src/internal/sandbox.ts +19 -0
  51. package/src/internal/tools.ts +48 -0
  52. package/src/internal/workspace.ts +25 -0
  53. package/src/kernel/diagnostics.ts +249 -0
  54. package/src/kernel/errors.ts +120 -0
  55. package/src/kernel/events.ts +82 -0
  56. package/src/kernel/index.ts +72 -0
  57. package/src/kernel/kernel.ts +62 -0
  58. package/src/kernel/lifecycle.ts +72 -0
  59. package/src/kernel/plugin.ts +218 -0
  60. package/src/kernel/registry.ts +493 -0
  61. package/src/kernel/scope.ts +776 -0
  62. package/src/kernel/service-key.ts +19 -0
  63. package/src/kernel/types.ts +317 -0
  64. package/src/memory/index.ts +2 -0
  65. package/src/memory/memory-tool.ts +75 -0
  66. package/src/memory/registry.ts +172 -0
  67. package/src/permission/ask-user-api.ts +70 -0
  68. package/src/permission/contracts.ts +135 -0
  69. package/src/permission/permission-request-manager.ts +58 -0
  70. package/src/permission/policy.ts +277 -0
  71. package/src/permission/runtime.ts +612 -0
  72. package/src/plugins/compaction-policy.ts +46 -0
  73. package/src/plugins/compose.ts +171 -0
  74. package/src/plugins/context-sections.ts +246 -0
  75. package/src/plugins/default-agent-driver.ts +14 -0
  76. package/src/plugins/facade-plugins.ts +129 -0
  77. package/src/plugins/goal-domain.ts +82 -0
  78. package/src/plugins/legacy-system-message.ts +152 -0
  79. package/src/plugins/loaded-tools.ts +23 -0
  80. package/src/plugins/memory-domain.ts +264 -0
  81. package/src/plugins/orchestrator-session.ts +29 -0
  82. package/src/plugins/permission-policy.ts +49 -0
  83. package/src/plugins/retry-policy.ts +28 -0
  84. package/src/plugins/scheduler-domain.ts +192 -0
  85. package/src/plugins/service-keys.ts +294 -0
  86. package/src/plugins/session-search-domain.ts +238 -0
  87. package/src/plugins/skills-domain.ts +272 -0
  88. package/src/plugins/subagent-domain.ts +287 -0
  89. package/src/plugins/tool-catalog.ts +78 -0
  90. package/src/plugins/tool-output-policy.ts +52 -0
  91. package/src/plugins/value-plugins.ts +150 -0
  92. package/src/plugins/workflow-domain.ts +198 -0
  93. package/src/plugins/workspace-policy.ts +37 -0
  94. package/src/providers/registry.ts +63 -0
  95. package/src/providers/types.ts +44 -0
  96. package/src/retry/policy.ts +282 -0
  97. package/src/retry/stream-chat.ts +312 -0
  98. package/src/runtime/agent-runtime.ts +83 -0
  99. package/src/runtime/default-agent-driver.ts +23 -0
  100. package/src/runtime/domain-tool-source.ts +156 -0
  101. package/src/runtime/events.ts +61 -0
  102. package/src/runtime/host-dependencies.ts +71 -0
  103. package/src/runtime/host-guidance.ts +22 -0
  104. package/src/runtime/host-layout.ts +23 -0
  105. package/src/runtime/host.ts +129 -0
  106. package/src/runtime/standalone-host.ts +118 -0
  107. package/src/sandbox/controller.ts +204 -0
  108. package/src/sandbox/model.ts +305 -0
  109. package/src/sandbox/provider.ts +53 -0
  110. package/src/sandbox/types.ts +110 -0
  111. package/src/scheduler/host.ts +22 -0
  112. package/src/scheduler/scheduler-tool.ts +172 -0
  113. package/src/session-search/host.ts +56 -0
  114. package/src/session-search/index.ts +23 -0
  115. package/src/session-search/session-search-tool.ts +151 -0
  116. package/src/skills/index.ts +3 -0
  117. package/src/skills/registry.ts +63 -0
  118. package/src/skills/skill-manage-tool.ts +205 -0
  119. package/src/skills/skill-tool.ts +42 -0
  120. package/src/storage/contracts.ts +159 -0
  121. package/src/storage/memory.ts +321 -0
  122. package/src/storage/options.ts +75 -0
  123. package/src/storage/runtime.ts +115 -0
  124. package/src/storage/sqlite-tool-output-artifacts.ts +106 -0
  125. package/src/storage/sqlite.ts +321 -0
  126. package/src/storage/tool-output-artifacts.ts +75 -0
  127. package/src/storage.ts +31 -0
  128. package/src/subagent/child-session.ts +282 -0
  129. package/src/subagent/guidance.ts +8 -0
  130. package/src/subagent/policy.ts +198 -0
  131. package/src/subagent/task-tool.ts +584 -0
  132. package/src/tool-output/contracts.ts +111 -0
  133. package/src/tool-output/policy.ts +410 -0
  134. package/src/tool.ts +1 -0
  135. package/src/tools/executor.ts +258 -0
  136. package/src/tools/install-manifest.ts +40 -0
  137. package/src/tools/llm-api.ts +77 -0
  138. package/src/tools/registry.ts +206 -0
  139. package/src/tools/tool-artifact.ts +182 -0
  140. package/src/tools/tool-source.ts +53 -0
  141. package/src/utils/errors.ts +334 -0
  142. package/src/utils/strip-visualization.ts +50 -0
  143. package/src/workflow/decomposer.ts +139 -0
  144. package/src/workflow/execution.ts +523 -0
  145. package/src/workflow/orchestrator-session.ts +161 -0
  146. package/src/workflow/synthesizer.ts +130 -0
  147. package/src/workspace/contracts.ts +135 -0
  148. package/src/workspace/policy.ts +327 -0
@@ -0,0 +1,71 @@
1
+ import type { Session } from '@capekai/types';
2
+ import { getRuntimeHost } from './host';
3
+ import type { RuntimeAudience, RuntimeDelivery, RuntimeEvent } from './events';
4
+
5
+ type HostRuntimeAudience = Exclude<RuntimeAudience, { scope: 'origin' }>;
6
+
7
+ export {
8
+ addMessageToQueue,
9
+ buildEffectiveContextHistory,
10
+ createMessage,
11
+ createPart,
12
+ createSession,
13
+ deleteMessage,
14
+ deleteQueuedMessage,
15
+ getAttachment,
16
+ getChildSessions,
17
+ getMessage,
18
+ getMessageWithParts,
19
+ getNextQueuedMessage,
20
+ getPart,
21
+ getPartsByMessage,
22
+ getPartsBySession,
23
+ getResponseFormat,
24
+ getSession,
25
+ getWorkspace,
26
+ getWorkspaceAutoApproveSeverity,
27
+ listLatestMessagesWithPartsPage,
28
+ listMessagesWithParts,
29
+ persistStreamingPartSnapshots,
30
+ syncMessageFts,
31
+ transitionToolToInterrupted,
32
+ transitionToolToRunningByCallId,
33
+ updateMessage,
34
+ updatePart,
35
+ updateSession,
36
+ } from '../storage/runtime';
37
+
38
+ export function emitRuntimeEvent(event: RuntimeEvent, audience: HostRuntimeAudience = { scope: 'global' }): void {
39
+ const delivery: RuntimeDelivery = { event, audience };
40
+ const host = getRuntimeHost().delivery;
41
+ host.observe?.(delivery);
42
+ host.emit(delivery);
43
+ }
44
+
45
+ export const emitSessionCreated = (session: Session): void =>
46
+ emitRuntimeEvent({ kind: 'session', action: 'created', session });
47
+ export const emitSessionUpdated = (session: Session): void =>
48
+ emitRuntimeEvent({ kind: 'session', action: 'updated', session });
49
+ export const emitToSession = (sessionId: string, event: RuntimeEvent): void =>
50
+ emitRuntimeEvent(event, { scope: 'session', sessionId });
51
+ export const emitToController = (sessionId: string, event: RuntimeEvent): void =>
52
+ emitRuntimeEvent(event, { scope: 'controller', sessionId });
53
+ export const emitToAskTargets = (
54
+ sessionId: string,
55
+ authority: Extract<RuntimeAudience, { scope: 'ask_targets' }>['authority'],
56
+ event: RuntimeEvent,
57
+ ): void => emitRuntimeEvent(event, { scope: 'ask_targets', sessionId, authority });
58
+ export const emitTerminal = (message: Extract<RuntimeEvent, { kind: 'terminal' }>['message'], sessionId: string): void =>
59
+ emitRuntimeEvent({ kind: 'terminal', message, sessionId }, { scope: 'host' });
60
+
61
+ export const isDefaultSessionTitle = (...args: Parameters<ReturnType<typeof getRuntimeHost>['titles']['isDefaultSessionTitle']>) =>
62
+ getRuntimeHost().titles.isDefaultSessionTitle(...args);
63
+ export const hasManualSessionTitle = (...args: Parameters<ReturnType<typeof getRuntimeHost>['titles']['hasManualSessionTitle']>) =>
64
+ getRuntimeHost().titles.hasManualSessionTitle(...args);
65
+ export const generateSessionTitle = (...args: Parameters<ReturnType<typeof getRuntimeHost>['titles']['generateSessionTitle']>) =>
66
+ getRuntimeHost().titles.generateSessionTitle(...args);
67
+ export const getToolWorkspaceHost = (...args: Parameters<ReturnType<typeof getRuntimeHost>['workspace']['createToolWorkspaceHost']>) =>
68
+ getRuntimeHost().workspace.createToolWorkspaceHost(...args);
69
+ export const isSandboxActive = (): boolean => getRuntimeHost().sandbox.isSandboxActive();
70
+
71
+ export type { RuntimeEventSink, RuntimeEventSink as BroadcastFn } from './events';
@@ -0,0 +1,22 @@
1
+ import { MEMORY_GUIDANCE } from '../memory';
2
+ import { AGENT_MEMORY_SKILLS_GUIDANCE } from '../plugins/legacy-system-message';
3
+ import { SKILL_MANAGE_GUIDANCE } from '../skills';
4
+ import { SESSION_SEARCH_GUIDANCE } from '../session-search';
5
+ import { getRuntimeHost } from './host';
6
+
7
+ export interface HostGuidance {
8
+ memory: string;
9
+ agentMemorySkills: string;
10
+ skillManage: string;
11
+ sessionSearch: string;
12
+ }
13
+
14
+ export function getHostGuidance(): HostGuidance {
15
+ const guidance = getRuntimeHost().guidance ?? {};
16
+ return {
17
+ memory: guidance.memory ?? MEMORY_GUIDANCE,
18
+ agentMemorySkills: guidance.agentMemorySkills ?? AGENT_MEMORY_SKILLS_GUIDANCE,
19
+ skillManage: guidance.skillManage ?? SKILL_MANAGE_GUIDANCE,
20
+ sessionSearch: guidance.sessionSearch ?? SESSION_SEARCH_GUIDANCE,
21
+ };
22
+ }
@@ -0,0 +1,23 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { getRuntimeHost } from './host';
4
+
5
+ export interface HostLayout {
6
+ workspaceMemoryDir(workspacePath: string): string;
7
+ workspaceSkillsDir(workspacePath: string): string;
8
+ agentSkillsDir(agentDir: string): string;
9
+ toolOutputTempRoot(): string;
10
+ }
11
+
12
+ function defaultLayout(): HostLayout {
13
+ return {
14
+ workspaceMemoryDir: (workspacePath) => path.join(workspacePath, '.capek'),
15
+ workspaceSkillsDir: (workspacePath) => path.join(workspacePath, '.agents', 'skills'),
16
+ agentSkillsDir: (agentDir) => path.join(agentDir, 'skills'),
17
+ toolOutputTempRoot: () => path.join(os.tmpdir(), 'capek'),
18
+ };
19
+ }
20
+
21
+ export function getHostLayout(): HostLayout {
22
+ return getRuntimeHost().layout ?? defaultLayout();
23
+ }
@@ -0,0 +1,129 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type { HostLayout } from './host-layout';
3
+ import type { Ask } from '@capekai/tool';
4
+ import type {
5
+ AskRequestMessage, AskTimedOutMessage, AutoApproveSeverity, MessageWithParts, Session,
6
+ } from '@capekai/types';
7
+ import type { PermissionGrant, PermissionGrantOptions, PermissionResource } from '@capekai/tool';
8
+ import type { WorkspaceCapabilityHost } from '../workspace/contracts';
9
+ import type { RuntimeDelivery, RuntimeEvent } from './events';
10
+
11
+ export type AskEventSink = (message: AskRequestMessage | AskTimedOutMessage) => void;
12
+ export type RuntimeEventSink = (event: RuntimeEvent) => void;
13
+ export type SessionEventSink = (session: Session) => void;
14
+ export type AskBroadcastFn = AskEventSink;
15
+ export type BroadcastFn = RuntimeEventSink;
16
+ export type BroadcastSessionFn = SessionEventSink;
17
+ export type PermissionRequestStatus = 'pending' | 'approved' | 'denied' | 'expired' | 'cancelled';
18
+
19
+ export interface PendingAskRecord {
20
+ id: string;
21
+ requestId: string;
22
+ sessionId: string;
23
+ rootSessionId?: string;
24
+ originSessionId?: string;
25
+ workspaceId?: string;
26
+ toolCallId: string;
27
+ toolName: string;
28
+ ask: Ask;
29
+ status: PermissionRequestStatus;
30
+ isPermission: boolean;
31
+ expiresAt?: number;
32
+ resolvedAt?: number;
33
+ resolution?: unknown;
34
+ createdAt: number;
35
+ }
36
+
37
+ export interface MatchGrantParams {
38
+ workspaceId: string;
39
+ toolName: string;
40
+ resource: PermissionResource;
41
+ action?: string;
42
+ permissionKey: string;
43
+ rootSessionId?: string;
44
+ }
45
+
46
+ export interface CreateGrantParams {
47
+ workspaceId: string;
48
+ toolName: string;
49
+ resource: PermissionResource;
50
+ action?: string;
51
+ permissionKey: string;
52
+ grantOptions: PermissionGrantOptions;
53
+ }
54
+
55
+ export interface InteractionHost {
56
+ createPendingAsk(record: Omit<PendingAskRecord, 'id'>): Promise<string>;
57
+ removePendingAsk(id: string): Promise<void>;
58
+ removePendingAsksByToolCallId(toolCallId: string): Promise<void>;
59
+ getPermissionRequestByRequestId(requestId: string): Promise<PendingAskRecord | null>;
60
+ resolvePermissionRequestByRequestId(requestId: string, status: 'approved' | 'denied', resolution?: unknown): Promise<boolean>;
61
+ expirePermissionRequest(id: string): Promise<boolean>;
62
+ expireOldPermissionRequests(maxAgeMs: number): Promise<number>;
63
+ cancelPendingRequestsBySession(sessionId: string): Promise<number>;
64
+ listPendingAsksBySession(sessionId: string): Promise<PendingAskRecord[]>;
65
+ listPendingAsksByRootSession(rootSessionId: string): Promise<PendingAskRecord[]>;
66
+ listPendingRequestsByRootSession(rootSessionId: string): Promise<PendingAskRecord[]>;
67
+ matchGrant(params: MatchGrantParams): Promise<{ matched: boolean; grant: PermissionGrant | null }>;
68
+ createGrantFromOptions(params: CreateGrantParams): Promise<PermissionGrant | null>;
69
+ getSessionAutoApproveSeverity(sessionId: string): Promise<AutoApproveSeverity | undefined>;
70
+ getPermissionTimeoutMs(): number;
71
+ notifyPermissionRequired(requestId: string, rootSessionId: string): Promise<void>;
72
+ }
73
+
74
+ export interface DeliveryHost {
75
+ emit(delivery: RuntimeDelivery): void;
76
+ observe?(delivery: RuntimeDelivery): void;
77
+ }
78
+
79
+ export interface TitleHost {
80
+ isDefaultSessionTitle(title: string | null | undefined): boolean;
81
+ hasManualSessionTitle(metadata: Record<string, unknown> | null | undefined): boolean;
82
+ generateSessionTitle(messages: MessageWithParts[]): Promise<string | null>;
83
+ }
84
+
85
+ export interface WorkspaceCapabilityBindings {
86
+ createToolWorkspaceHost(options: {
87
+ workspaceId?: string;
88
+ workspacePath?: string;
89
+ additionalPaths?: string[];
90
+ sessionId: string;
91
+ }): WorkspaceCapabilityHost;
92
+ }
93
+
94
+ export interface SandboxBindings {
95
+ isSandboxActive(): boolean;
96
+ }
97
+
98
+ export interface RuntimeHost {
99
+ interaction: InteractionHost;
100
+ delivery: DeliveryHost;
101
+ titles: TitleHost;
102
+ workspace: WorkspaceCapabilityBindings;
103
+ sandbox: SandboxBindings;
104
+ /** Host-supplied filesystem layout policy. */
105
+ layout?: HostLayout;
106
+ guidance?: {
107
+ memory?: string;
108
+ agentMemorySkills?: string;
109
+ skillManage?: string;
110
+ sessionSearch?: string;
111
+ };
112
+ }
113
+
114
+ let host: RuntimeHost | null = null;
115
+ const scopedHost = new AsyncLocalStorage<RuntimeHost>();
116
+
117
+ export function configureRuntimeHost(value: RuntimeHost): void {
118
+ host = value;
119
+ }
120
+
121
+ export function withRuntimeHost<T>(value: RuntimeHost, callback: () => T): T {
122
+ return scopedHost.run(value, callback);
123
+ }
124
+
125
+ export function getRuntimeHost(): RuntimeHost {
126
+ const active = scopedHost.getStore() ?? host;
127
+ if (!active) throw new Error('Runtime host has not been configured');
128
+ return active;
129
+ }
@@ -0,0 +1,118 @@
1
+ import { join } from 'node:path';
2
+ import type { AutoApproveSeverity } from '@capekai/types';
3
+ import { PermissionGrant } from '@capekai/tool';
4
+ import type {
5
+ PendingAskRecord,
6
+ RuntimeHost,
7
+ } from './host';
8
+
9
+ interface StandaloneHostOptions {
10
+ workspace: string;
11
+ sandboxActive: boolean;
12
+ tempRoot: string;
13
+ }
14
+
15
+ /** The reference headless `RuntimeHost`. In-memory pending-ask bookkeeping,
16
+ * no grant persistence (every permission ask reaches the host every time),
17
+ * no auto-approve, no-op delivery/titles, process env for tool workspaces.
18
+ * Copy this as the starting point for your own host and replace the
19
+ * behavior you care about. */
20
+ export function createStandaloneHost(options: StandaloneHostOptions): RuntimeHost {
21
+ const pending = new Map<string, PendingAskRecord>();
22
+ const recordIdByRequest = new Map<string, string>();
23
+ let sequence = 0;
24
+
25
+ const records = (): PendingAskRecord[] => [...pending.values()];
26
+ const byRequest = (requestId: string): PendingAskRecord | null => {
27
+ const id = recordIdByRequest.get(requestId);
28
+ return id ? pending.get(id) ?? null : null;
29
+ };
30
+
31
+ return {
32
+ interaction: {
33
+ async createPendingAsk(record) {
34
+ const id = `standalone-ask-${++sequence}`;
35
+ pending.set(id, { ...record, id });
36
+ recordIdByRequest.set(record.requestId, id);
37
+ return id;
38
+ },
39
+ async removePendingAsk(id) {
40
+ const record = pending.get(id);
41
+ if (record) recordIdByRequest.delete(record.requestId);
42
+ pending.delete(id);
43
+ },
44
+ async removePendingAsksByToolCallId(toolCallId) {
45
+ for (const record of records()) {
46
+ if (record.toolCallId === toolCallId) {
47
+ pending.delete(record.id);
48
+ recordIdByRequest.delete(record.requestId);
49
+ }
50
+ }
51
+ },
52
+ getPermissionRequestByRequestId: async (requestId) => byRequest(requestId),
53
+ async resolvePermissionRequestByRequestId(requestId, status, resolution) {
54
+ const record = byRequest(requestId);
55
+ if (!record || record.status !== 'pending') return false;
56
+ pending.set(record.id, { ...record, status, resolution, resolvedAt: Date.now() });
57
+ return true;
58
+ },
59
+ async expirePermissionRequest(id) {
60
+ const record = pending.get(id);
61
+ if (!record || record.status !== 'pending') return false;
62
+ pending.set(id, { ...record, status: 'expired', resolvedAt: Date.now() });
63
+ return true;
64
+ },
65
+ async expireOldPermissionRequests(maxAgeMs) {
66
+ const cutoff = Date.now() - maxAgeMs;
67
+ let count = 0;
68
+ for (const record of records()) {
69
+ if (record.status === 'pending' && record.createdAt < cutoff) {
70
+ pending.set(record.id, { ...record, status: 'expired', resolvedAt: Date.now() });
71
+ count += 1;
72
+ }
73
+ }
74
+ return count;
75
+ },
76
+ async cancelPendingRequestsBySession(sessionId) {
77
+ let count = 0;
78
+ for (const record of records()) {
79
+ if (record.sessionId === sessionId && record.status === 'pending') {
80
+ pending.set(record.id, { ...record, status: 'cancelled', resolvedAt: Date.now() });
81
+ count += 1;
82
+ }
83
+ }
84
+ return count;
85
+ },
86
+ listPendingAsksBySession: async (sessionId) => records().filter((record) => record.sessionId === sessionId && record.status === 'pending'),
87
+ listPendingAsksByRootSession: async (rootSessionId) => records().filter((record) => record.rootSessionId === rootSessionId && record.status === 'pending'),
88
+ listPendingRequestsByRootSession: async (rootSessionId) => records().filter((record) => record.rootSessionId === rootSessionId && record.status === 'pending'),
89
+ matchGrant: async () => ({ matched: false, grant: null }),
90
+ createGrantFromOptions: async () => null as PermissionGrant | null,
91
+ getSessionAutoApproveSeverity: async () => undefined as AutoApproveSeverity | undefined,
92
+ getPermissionTimeoutMs: () => 30 * 60 * 1000,
93
+ notifyPermissionRequired: async () => {},
94
+ },
95
+ delivery: {
96
+ emit: () => {},
97
+ },
98
+ titles: {
99
+ isDefaultSessionTitle: () => true,
100
+ hasManualSessionTitle: () => false,
101
+ generateSessionTitle: async () => null,
102
+ },
103
+ workspace: {
104
+ createToolWorkspaceHost({ workspacePath, additionalPaths, sessionId }) {
105
+ return {
106
+ root: workspacePath ?? options.workspace,
107
+ additionalRoots: additionalPaths,
108
+ allowedRoots: [],
109
+ tempDir: join(options.tempRoot, sessionId),
110
+ getEnvironmentValue: (key) => process.env[key],
111
+ };
112
+ },
113
+ },
114
+ sandbox: {
115
+ isSandboxActive: () => options.sandboxActive,
116
+ },
117
+ };
118
+ }
@@ -0,0 +1,204 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import type {
3
+ AutoResponderRule,
4
+ LlmCallContext,
5
+ SandboxControlEvent,
6
+ SandboxHistoryEntry,
7
+ SandboxResponse,
8
+ } from './types';
9
+
10
+ interface PendingCall {
11
+ context: LlmCallContext;
12
+ resolve: (response: SandboxResponse) => void;
13
+ reject: (reason: unknown) => void;
14
+ }
15
+
16
+ interface InternalAutoResponderRule extends AutoResponderRule {
17
+ uses: number;
18
+ }
19
+
20
+ function cloneResponse(response: SandboxResponse): SandboxResponse {
21
+ if (response.type === 'multi-tool-call') {
22
+ return {
23
+ ...response,
24
+ calls: response.calls.map((call) => ({ ...call, args: { ...call.args } })),
25
+ };
26
+ }
27
+ if (response.type === 'tool-call') {
28
+ return { ...response, args: { ...response.args } };
29
+ }
30
+ return { ...response };
31
+ }
32
+
33
+ function hasToolResults(context: LlmCallContext): boolean {
34
+ return context.messages.some((message) => Array.isArray(message.content)
35
+ && message.content.some((part) => part !== null
36
+ && typeof part === 'object'
37
+ && (part as { type?: unknown }).type === 'tool-result'));
38
+ }
39
+
40
+ function matchesScalar<T extends string | number>(value: T, matcher: T | T[] | undefined): boolean {
41
+ if (matcher === undefined) return true;
42
+ return Array.isArray(matcher) ? matcher.includes(value) : matcher === value;
43
+ }
44
+
45
+ export class SandboxController {
46
+ private pendingCalls = new Map<string, PendingCall>();
47
+ private history: SandboxHistoryEntry[] = [];
48
+ private autoResponderRules: InternalAutoResponderRule[] = [];
49
+ private broadcastEvent: ((event: SandboxControlEvent) => void) | null = null;
50
+
51
+ constructor(initialRules: AutoResponderRule[] = []) {
52
+ this.setAutoResponderRules(initialRules);
53
+ }
54
+
55
+ async waitForResponse(context: LlmCallContext, abortSignal?: AbortSignal): Promise<SandboxResponse> {
56
+ const historyEntry: SandboxHistoryEntry = {
57
+ callId: context.callId,
58
+ context,
59
+ response: null,
60
+ respondedAt: null,
61
+ completedAt: null,
62
+ };
63
+ this.history.push(historyEntry);
64
+
65
+ const rule = this.findMatchingRule(context);
66
+ if (rule) {
67
+ const response = cloneResponse(rule.response);
68
+ historyEntry.response = response;
69
+ historyEntry.respondedAt = Date.now();
70
+ this.broadcastHistory();
71
+ return response;
72
+ }
73
+
74
+ this.broadcast({ type: 'sandbox.call_waiting', context });
75
+ this.broadcastHistory();
76
+
77
+ return new Promise<SandboxResponse>((resolve, reject) => {
78
+ this.pendingCalls.set(context.callId, { context, resolve, reject });
79
+ if (!abortSignal) return;
80
+ if (abortSignal.aborted) {
81
+ this.pendingCalls.delete(context.callId);
82
+ reject(new DOMException('The operation was aborted.', 'AbortError'));
83
+ return;
84
+ }
85
+ abortSignal.addEventListener('abort', () => {
86
+ if (this.pendingCalls.has(context.callId)) {
87
+ this.pendingCalls.delete(context.callId);
88
+ reject(new DOMException('The operation was aborted.', 'AbortError'));
89
+ }
90
+ }, { once: true });
91
+ });
92
+ }
93
+
94
+ respond(callId: string, response: SandboxResponse): void {
95
+ const pending = this.pendingCalls.get(callId);
96
+ if (!pending) throw new Error(`No pending call with id: ${callId}`);
97
+ this.pendingCalls.delete(callId);
98
+ const entry = this.history.find((candidate) => candidate.callId === callId);
99
+ if (entry) {
100
+ entry.response = cloneResponse(response);
101
+ entry.respondedAt = Date.now();
102
+ }
103
+ this.broadcastHistory();
104
+ pending.resolve(response);
105
+ }
106
+
107
+ rejectPendingCall(callId: string, reason: unknown): void {
108
+ const pending = this.pendingCalls.get(callId);
109
+ if (!pending) return;
110
+ this.pendingCalls.delete(callId);
111
+ pending.reject(reason);
112
+ }
113
+
114
+ rejectAllPendingForSession(sessionId: string): string[] {
115
+ const rejectedCallIds: string[] = [];
116
+ for (const [callId, pending] of this.pendingCalls) {
117
+ if (pending.context.sessionId !== sessionId) continue;
118
+ this.pendingCalls.delete(callId);
119
+ pending.reject(new DOMException('The operation was aborted.', 'AbortError'));
120
+ rejectedCallIds.push(callId);
121
+ }
122
+ return rejectedCallIds;
123
+ }
124
+
125
+ complete(callId: string): void {
126
+ const entry = this.history.find((candidate) => candidate.callId === callId);
127
+ if (entry) entry.completedAt = Date.now();
128
+ this.broadcast({ type: 'sandbox.call_completed', callId });
129
+ this.broadcastHistory();
130
+ }
131
+
132
+ getPendingCalls(): LlmCallContext[] {
133
+ return Array.from(this.pendingCalls.values()).map((pending) => pending.context);
134
+ }
135
+
136
+ getPendingCall(callId: string): LlmCallContext | undefined {
137
+ return this.pendingCalls.get(callId)?.context;
138
+ }
139
+
140
+ getHistory(): SandboxHistoryEntry[] {
141
+ return [...this.history];
142
+ }
143
+
144
+ clearHistory(): void {
145
+ this.history = [];
146
+ this.broadcastHistory();
147
+ }
148
+
149
+ getAutoResponderRules(): AutoResponderRule[] {
150
+ return this.autoResponderRules.map(({ uses: _uses, ...rule }) => rule);
151
+ }
152
+
153
+ setAutoResponderRules(rules: AutoResponderRule[]): void {
154
+ this.autoResponderRules = rules.map((rule) => ({ ...rule, response: cloneResponse(rule.response), uses: 0 }));
155
+ }
156
+
157
+ setBroadcast(fn: ((event: SandboxControlEvent) => void) | null): void {
158
+ this.broadcastEvent = fn;
159
+ }
160
+
161
+ reset(): void {
162
+ for (const pending of this.pendingCalls.values()) pending.reject(new Error('Sandbox controller reset'));
163
+ this.pendingCalls = new Map<string, PendingCall>();
164
+ this.history = [];
165
+ this.autoResponderRules = [];
166
+ this.broadcastEvent = null;
167
+ }
168
+
169
+ private findMatchingRule(context: LlmCallContext): InternalAutoResponderRule | null {
170
+ for (const rule of this.autoResponderRules) {
171
+ if (!matchesScalar(context.mode, rule.match.mode)
172
+ || !matchesScalar(context.depth, rule.match.depth)
173
+ || !matchesScalar(context.sessionId, rule.match.sessionId)
174
+ || (rule.match.hasToolResults !== undefined && rule.match.hasToolResults !== hasToolResults(context))) {
175
+ continue;
176
+ }
177
+ rule.uses += 1;
178
+ if (rule.maxUses !== undefined && rule.uses >= rule.maxUses) {
179
+ this.autoResponderRules = this.autoResponderRules.filter((candidate) => candidate !== rule);
180
+ }
181
+ return rule;
182
+ }
183
+ return null;
184
+ }
185
+
186
+ private broadcast(event: SandboxControlEvent): void {
187
+ this.broadcastEvent?.(event);
188
+ }
189
+
190
+ private broadcastHistory(): void {
191
+ this.broadcast({ type: 'sandbox.history', entries: this.getHistory() });
192
+ }
193
+ }
194
+
195
+ export const sandboxController = new SandboxController();
196
+ const scopedSandboxController = new AsyncLocalStorage<SandboxController>();
197
+
198
+ export function getSandboxController(): SandboxController {
199
+ return scopedSandboxController.getStore() ?? sandboxController;
200
+ }
201
+
202
+ export function withSandboxController<T>(controller: SandboxController, callback: () => T): T {
203
+ return scopedSandboxController.run(controller, callback);
204
+ }