diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml
index e0220b822..a7e6f3f7d 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.puml
+++ b/packages/agent-core-v2/docs/di-scope-domains.puml
@@ -23,7 +23,9 @@ package "Core scope (process-wide)" #EAF3FB {
rectangle "telemetry\nCore\n ITelemetryService" as telemetry #D6EAF8
rectangle "event\nCore\n IEventService" as event #D6EAF8
rectangle "gateway\nCore\n IScopeRegistry\n IRestGateway\n IWSGateway\n IWSBroadcastService" as gateway #D6EAF8
- rectangle "filestore\nCore\n IFileStore" as filestore #D6EAF8
+ rectangle "hostFs\nCore\n IHostFileSystem" as hostFs #D6EAF8
+ rectangle "workspaceRegistry\nCore\n IWorkspaceRegistry" as workspaceRegistry #D6EAF8
+ rectangle "hostFolderBrowser\nCore\n IHostFolderBrowser" as hostFolderBrowser #D6EAF8
rectangle "auth\nCore\n IOAuthService\n IAuthSummaryService" as auth #D6EAF8
rectangle "provider\nCore\n IProviderService" as provider #D6EAF8
rectangle "flag\nCore\n IFlagService" as flag #D6EAF8
@@ -31,12 +33,13 @@ package "Core scope (process-wide)" #EAF3FB {
package "Session scope (per session)" #EAFAF1 {
rectangle "session\nSession\n ISessionService" as session #D5F5E3
- rectangle "fs\nSession\n IFsService\n IFsSearchService\n IFsGitService\n IFsWatcher" as fs #D5F5E3
+ rectangle "workspaceContext\nSession\n IWorkspaceContext" as workspaceContext #D5F5E3
+ rectangle "agentFs\nSession\n IAgentFileSystem\n IFileSystemBackend" as agentFs #D5F5E3
rectangle "approval\nSession\n IApprovalService" as approval #D5F5E3
rectangle "question\nSession\n IQuestionService" as question #D5F5E3
rectangle "session-activity\nSession\n ISessionActivity" as session_activity #D5F5E3
rectangle "subagentHost\nSession\n ISubagentHost" as subagentHost #D5F5E3
- rectangle "terminal\nSession\n ITerminalService" as terminal #D5F5E3
+ rectangle "process\nSession\n IProcessRunner\n IProcessBackend" as process #D5F5E3
rectangle "agent-lifecycle\nSession\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
rectangle "session-context\nSession\n ISessionContext (seed)" as session_context #D5F5E3
}
@@ -86,8 +89,6 @@ package "Multi-scope" #F5EEF8 {
rectangle "kosong\nMulti\n IProtocolHandlerRegistry(C)\n IProviderManager(S, seed)" as kosong #E8DAEF
rectangle "records\nMulti\n ISessionStore(C)\n ISessionMetaStore(S)\n IAgentRecords(A)" as records #E8DAEF
rectangle "tool\nMulti\n IToolDefinitionRegistry(C)\n IToolService(A)" as tool #E8DAEF
- rectangle "kaos\nMulti\n IKaosFactory(C)\n ISessionKaosService(S)\n IKaosService / IAgentKaos(A)" as kaos #E8DAEF
- rectangle "workspace\nMulti\n IWorkspaceRegistry(C)\n IWorkspaceFsService(C)\n ISessionWorkspaceService(S)" as workspace #E8DAEF
}
package "WIP (contract only)" #FBFCFC {
@@ -98,12 +99,10 @@ package "WIP (contract only)" #FBFCFC {
' ---- DI injection (solid) ----
gateway --> event #34495E
-workspace --> environment #34495E
-workspace --> event #34495E
-workspace --> records #34495E
-workspace --> log #34495E
-workspace --> kaos #34495E
-filestore --> kaos #34495E
+agentFs --> workspaceContext #34495E
+agentFs --> hostFs #34495E
+process --> workspaceContext #34495E
+hostFolderBrowser --> hostFs #34495E
auth --> provider #34495E
auth --> environment #34495E
auth --> telemetry #34495E
@@ -113,26 +112,19 @@ flag --> config #34495E
config --> environment #34495E
config --> log #34495E
config --> records #34495E
-config --> kaos #34495E
kosong --> config #34495E
-records --> kaos #34495E
+records --> hostFs #34495E
+records --> storage #34495E
records --> log #34495E
tool --> config #34495E
tool --> records #34495E
-tool --> kaos #34495E
tool --> permission #34495E
tool --> kosong #34495E
-kaos --> environment #34495E
-kaos --> log #34495E
session --> records #34495E
session --> agent_lifecycle #34495E
session --> session_activity #34495E
session --> event #34495E
-fs --> kaos #34495E
-fs --> log #34495E
session_activity --> agent_lifecycle #34495E
-terminal --> log #34495E
-terminal --> kaos #34495E
agent_lifecycle --> session_context #34495E
agent_lifecycle --> records #34495E
permissionMode --> wireRecord #34495E
@@ -278,7 +270,7 @@ turn --> tool #34495E
plan --> contextMemory #34495E
plan --> wireRecord #34495E
plan --> eventBus #34495E
-plan --> kaos #34495E
+plan --> agentFs #34495E
plan --> profile #34495E
plan --> replayBuilder #34495E
plan --> toolRegistry #34495E
diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs
index dd36a4508..03f7c9cb5 100644
--- a/packages/agent-core-v2/scripts/check-domain-layers.mjs
+++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs
@@ -42,7 +42,8 @@ const DOMAIN_LAYER = new Map([
['log', 1],
['telemetry', 1],
['environment', 1],
- ['kaos', 1],
+ ['hostFs', 1],
+ ['workspaceContext', 1],
['kosong', 1],
['hooks', 1],
['storage', 1],
@@ -51,10 +52,10 @@ const DOMAIN_LAYER = new Map([
['wireRecord', 2],
['blobStore', 2],
['config', 2],
- ['terminal', 2],
- ['fs', 2],
- ['workspace', 2],
- ['filestore', 2],
+ ['agentFs', 2],
+ ['process', 2],
+ ['workspaceRegistry', 2],
+ ['hostFolderBrowser', 2],
['auth', 2],
['provider', 2],
['sessionMetaStore', 2],
@@ -160,7 +161,6 @@ const ALLOWED_EXCEPTIONS = new Set([
'cron>session-context',
'cron>session-activity',
'session>event',
- 'workspace>event',
'wireRecord>hooks',
// L3/L4 type-sharing introduced by the rebase-v2 restructuring.
'config>externalHooks',
diff --git a/packages/agent-core-v2/src/agentFs/agentFs.ts b/packages/agent-core-v2/src/agentFs/agentFs.ts
new file mode 100644
index 000000000..8fc7cd06c
--- /dev/null
+++ b/packages/agent-core-v2/src/agentFs/agentFs.ts
@@ -0,0 +1,52 @@
+/**
+ * `agentFs` domain (L1) — the Agent's filesystem and its pluggable backend.
+ *
+ * Defines the `IAgentFileSystem` that business code injects to read and write
+ * files inside the Agent's execution environment, plus the internal
+ * `IFileSystemBackend` provider that hides the local/ssh/container split.
+ * Session-scoped. Business code depends on `IAgentFileSystem` only; the
+ * backend is wired through the scope registry.
+ */
+
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+
+export interface AgentFileStat {
+ readonly isFile: boolean;
+ readonly isDirectory: boolean;
+ readonly size: number;
+}
+
+export interface IAgentFileSystem {
+ readonly _serviceBrand: undefined;
+
+ readonly cwd: string;
+
+ readText(path: string): Promise;
+ writeText(path: string, data: string): Promise;
+ readBytes(path: string): Promise;
+ writeBytes(path: string, data: Uint8Array): Promise;
+ stat(path: string): Promise;
+ readdir(path: string): Promise;
+ glob(pattern: string): Promise;
+ mkdir(path: string): Promise;
+ withCwd(cwd: string): IAgentFileSystem;
+}
+
+export const IAgentFileSystem: ServiceIdentifier =
+ createDecorator('agentFileSystem');
+
+export interface IFileSystemBackend {
+ readonly _serviceBrand: undefined;
+
+ readText(absPath: string): Promise;
+ writeText(absPath: string, data: string): Promise;
+ readBytes(absPath: string): Promise;
+ writeBytes(absPath: string, data: Uint8Array): Promise;
+ stat(absPath: string): Promise;
+ readdir(absPath: string): Promise;
+ glob(absDir: string, pattern: string): Promise;
+ mkdir(absPath: string): Promise;
+}
+
+export const IFileSystemBackend: ServiceIdentifier =
+ createDecorator('fileSystemBackend');
diff --git a/packages/agent-core-v2/src/agentFs/agentFsService.ts b/packages/agent-core-v2/src/agentFs/agentFsService.ts
new file mode 100644
index 000000000..09b2dcab9
--- /dev/null
+++ b/packages/agent-core-v2/src/agentFs/agentFsService.ts
@@ -0,0 +1,77 @@
+/**
+ * `agentFs` domain (L1) — `IAgentFileSystem` implementation.
+ *
+ * Resolves paths against the session workspace and delegates IO to the
+ * injected `IFileSystemBackend`; reads the work directory through
+ * `workspaceContext`. Bound at Session scope.
+ */
+
+import { isAbsolute, resolve } from 'node:path';
+
+import { InstantiationType } from '#/_base/di/extensions';
+import { NotImplementedError } from '#/_base/errors';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { IWorkspaceContext } from '#/workspaceContext';
+
+import { type AgentFileStat, IAgentFileSystem, IFileSystemBackend } from './agentFs';
+
+export class AgentFileSystem implements IAgentFileSystem {
+ declare readonly _serviceBrand: undefined;
+
+ constructor(
+ @IFileSystemBackend private readonly backend: IFileSystemBackend,
+ @IWorkspaceContext private readonly workspace: IWorkspaceContext,
+ ) {}
+
+ get cwd(): string {
+ return this.workspace.workDir;
+ }
+
+ private abs(path: string): string {
+ return isAbsolute(path) ? path : resolve(this.cwd, path);
+ }
+
+ readText(path: string): Promise {
+ return this.backend.readText(this.abs(path));
+ }
+
+ writeText(path: string, data: string): Promise {
+ return this.backend.writeText(this.abs(path), data);
+ }
+
+ readBytes(path: string): Promise {
+ return this.backend.readBytes(this.abs(path));
+ }
+
+ writeBytes(path: string, data: Uint8Array): Promise {
+ return this.backend.writeBytes(this.abs(path), data);
+ }
+
+ stat(path: string): Promise {
+ return this.backend.stat(this.abs(path));
+ }
+
+ readdir(path: string): Promise {
+ return this.backend.readdir(this.abs(path));
+ }
+
+ glob(pattern: string): Promise {
+ return this.backend.glob(this.cwd, pattern);
+ }
+
+ mkdir(path: string): Promise {
+ return this.backend.mkdir(this.abs(path));
+ }
+
+ withCwd(_cwd: string): IAgentFileSystem {
+ throw new NotImplementedError('agentFs.withCwd');
+ }
+}
+
+registerScopedService(
+ LifecycleScope.Session,
+ IAgentFileSystem,
+ AgentFileSystem,
+ InstantiationType.Delayed,
+ 'agentFs',
+);
diff --git a/packages/agent-core-v2/src/agentFs/index.ts b/packages/agent-core-v2/src/agentFs/index.ts
new file mode 100644
index 000000000..77946c5b6
--- /dev/null
+++ b/packages/agent-core-v2/src/agentFs/index.ts
@@ -0,0 +1,12 @@
+/**
+ * `agentFs` domain barrel — re-exports the agent-filesystem contract
+ * (`agentFs`), its scoped service (`agentFsService`), and the backend
+ * implementations (`localFileSystemBackend`, `sshFileSystemBackend`).
+ * Importing this barrel registers the `IAgentFileSystem` and default local
+ * `IFileSystemBackend` bindings into the scope registry.
+ */
+
+export * from './agentFs';
+export * from './agentFsService';
+export * from './localFileSystemBackend';
+export * from './sshFileSystemBackend';
diff --git a/packages/agent-core-v2/src/agentFs/localFileSystemBackend.ts b/packages/agent-core-v2/src/agentFs/localFileSystemBackend.ts
new file mode 100644
index 000000000..eda7c773e
--- /dev/null
+++ b/packages/agent-core-v2/src/agentFs/localFileSystemBackend.ts
@@ -0,0 +1,63 @@
+/**
+ * `agentFs` domain (L1) — local `IFileSystemBackend` implementation.
+ *
+ * Backs the Agent filesystem with the real local disk by delegating to the
+ * program-side `hostFs` primitives. Registered as the default
+ * `IFileSystemBackend` at Session scope; remote backends override it via the
+ * scope registry.
+ */
+
+import { InstantiationType } from '#/_base/di/extensions';
+import { NotImplementedError } from '#/_base/errors';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { IHostFileSystem } from '#/hostFs';
+
+import { type AgentFileStat, IFileSystemBackend } from './agentFs';
+
+export class LocalFileSystemBackend implements IFileSystemBackend {
+ declare readonly _serviceBrand: undefined;
+
+ constructor(@IHostFileSystem private readonly hostFs: IHostFileSystem) {}
+
+ readText(absPath: string): Promise {
+ return this.hostFs.readText(absPath);
+ }
+
+ writeText(absPath: string, data: string): Promise {
+ return this.hostFs.writeText(absPath, data);
+ }
+
+ readBytes(absPath: string): Promise {
+ return this.hostFs.readBytes(absPath);
+ }
+
+ writeBytes(absPath: string, data: Uint8Array): Promise {
+ return this.hostFs.writeBytes(absPath, data);
+ }
+
+ async stat(absPath: string): Promise {
+ const s = await this.hostFs.stat(absPath);
+ return { isFile: s.isFile, isDirectory: s.isDirectory, size: s.size };
+ }
+
+ async readdir(absPath: string): Promise {
+ const entries = await this.hostFs.readdir(absPath);
+ return entries.map((e) => e.name);
+ }
+
+ glob(_absDir: string, _pattern: string): Promise {
+ throw new NotImplementedError('localFileSystemBackend.glob');
+ }
+
+ mkdir(absPath: string): Promise {
+ return this.hostFs.mkdir(absPath, { recursive: true });
+ }
+}
+
+registerScopedService(
+ LifecycleScope.Session,
+ IFileSystemBackend,
+ LocalFileSystemBackend,
+ InstantiationType.Delayed,
+ 'agentFs',
+);
diff --git a/packages/agent-core-v2/src/agentFs/sshFileSystemBackend.ts b/packages/agent-core-v2/src/agentFs/sshFileSystemBackend.ts
new file mode 100644
index 000000000..7fbba92a8
--- /dev/null
+++ b/packages/agent-core-v2/src/agentFs/sshFileSystemBackend.ts
@@ -0,0 +1,47 @@
+/**
+ * `agentFs` domain (L1) — ssh `IFileSystemBackend` stub.
+ *
+ * Placeholder for the remote backend; not registered into the scope registry
+ * yet. A composition root that needs ssh supplies it through
+ * `ScopeOptions.extra` to override the local backend.
+ */
+
+import { NotImplementedError } from '#/_base/errors';
+
+import { type AgentFileStat, IFileSystemBackend } from './agentFs';
+
+export class SshFileSystemBackend implements IFileSystemBackend {
+ declare readonly _serviceBrand: undefined;
+
+ readText(_absPath: string): Promise {
+ throw new NotImplementedError('sshFileSystemBackend');
+ }
+
+ writeText(_absPath: string, _data: string): Promise {
+ throw new NotImplementedError('sshFileSystemBackend');
+ }
+
+ readBytes(_absPath: string): Promise {
+ throw new NotImplementedError('sshFileSystemBackend');
+ }
+
+ writeBytes(_absPath: string, _data: Uint8Array): Promise {
+ throw new NotImplementedError('sshFileSystemBackend');
+ }
+
+ stat(_absPath: string): Promise {
+ throw new NotImplementedError('sshFileSystemBackend');
+ }
+
+ readdir(_absPath: string): Promise {
+ throw new NotImplementedError('sshFileSystemBackend');
+ }
+
+ glob(_absDir: string, _pattern: string): Promise {
+ throw new NotImplementedError('sshFileSystemBackend');
+ }
+
+ mkdir(_absPath: string): Promise {
+ throw new NotImplementedError('sshFileSystemBackend');
+ }
+}
diff --git a/packages/agent-core-v2/src/environment/environment.ts b/packages/agent-core-v2/src/environment/environment.ts
index a48f090a2..ef6f2b537 100644
--- a/packages/agent-core-v2/src/environment/environment.ts
+++ b/packages/agent-core-v2/src/environment/environment.ts
@@ -1,10 +1,11 @@
/**
* `environment` domain (L1) — resolved environment paths and OS probe.
*
- * Defines the public contract of the environment: the resolved paths
- * (`homeDir`, `configPath`) and the `IEnvironmentService` used by other
- * domains to locate config and detect the host `Environment`, plus the
- * Core-scope `environmentSeed`. Core-scoped — one shared instance.
+ * Defines the public contract of the environment: the resolved paths the app
+ * writes to (`homeDir`, `configPath`, `sessionsDir`, `blobsDir`, `storeDir`,
+ * `cacheDir`, `logsDir`) and the `IEnvironmentService` used by other domains
+ * to locate those paths and detect the host `Environment`, plus the Core-scope
+ * `environmentSeed`. Pure configuration — it performs no IO. Core-scoped.
*/
import type { Environment } from '@moonshot-ai/kaos';
@@ -23,6 +24,11 @@ export interface IEnvironmentService {
readonly _serviceBrand: undefined;
readonly homeDir: string;
readonly configPath: string;
+ readonly sessionsDir: string;
+ readonly blobsDir: string;
+ readonly storeDir: string;
+ readonly cacheDir: string;
+ readonly logsDir: string;
detect(): Promise;
}
diff --git a/packages/agent-core-v2/src/environment/environmentService.ts b/packages/agent-core-v2/src/environment/environmentService.ts
index cdd041ddc..7f0458dd8 100644
--- a/packages/agent-core-v2/src/environment/environmentService.ts
+++ b/packages/agent-core-v2/src/environment/environmentService.ts
@@ -21,11 +21,21 @@ export class EnvironmentService implements IEnvironmentService {
declare readonly _serviceBrand: undefined;
readonly homeDir: string;
readonly configPath: string;
+ readonly sessionsDir: string;
+ readonly blobsDir: string;
+ readonly storeDir: string;
+ readonly cacheDir: string;
+ readonly logsDir: string;
private detected?: Promise;
constructor(@IEnvironmentOptions options: IEnvironmentOptions) {
this.homeDir = options.homeDir;
this.configPath = join(options.homeDir, 'config.toml');
+ this.sessionsDir = join(options.homeDir, 'sessions');
+ this.blobsDir = join(options.homeDir, 'blobs');
+ this.storeDir = join(options.homeDir, 'store');
+ this.cacheDir = join(options.homeDir, 'cache');
+ this.logsDir = join(options.homeDir, 'logs');
}
detect(): Promise {
diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts
index 4af35fba1..deb9fc327 100644
--- a/packages/agent-core-v2/src/errors.ts
+++ b/packages/agent-core-v2/src/errors.ts
@@ -22,7 +22,6 @@ import { ProfileErrors } from '#/profile/errors';
import { PromptErrors } from '#/prompt/errors';
import { SessionErrors } from '#/session/errors';
import { SkillErrors } from '#/skill/errors';
-import { TerminalErrors } from '#/terminal/errors';
import { TurnErrors } from '#/turn/errors';
import { WireRecordErrors } from '#/wireRecord/errors';
@@ -41,7 +40,6 @@ export { ProfileErrors } from '#/profile/errors';
export { PromptErrors } from '#/prompt/errors';
export { SessionErrors } from '#/session/errors';
export { SkillErrors } from '#/skill/errors';
-export { TerminalErrors } from '#/terminal/errors';
export { TurnErrors } from '#/turn/errors';
export { WireRecordErrors } from '#/wireRecord/errors';
@@ -61,7 +59,6 @@ export const ErrorCodes = {
...PromptErrors.codes,
...SessionErrors.codes,
...SkillErrors.codes,
- ...TerminalErrors.codes,
...TurnErrors.codes,
...WireRecordErrors.codes,
} as const;
diff --git a/packages/agent-core-v2/src/filestore/fileStoreService.ts b/packages/agent-core-v2/src/filestore/fileStoreService.ts
deleted file mode 100644
index 5935ded78..000000000
--- a/packages/agent-core-v2/src/filestore/fileStoreService.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * `filestore` domain (cross-cutting) — `IFileStore` implementation.
- *
- * Stores and retrieves blobs keyed by string; uses the execution environment
- * through `kaos`. Bound at Core scope.
- */
-
-import { InstantiationType } from '#/_base/di/extensions';
-import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import { IKaosFactory } from '#/kaos';
-
-import { IFileStore } from './filestore';
-
-export class FileStore implements IFileStore {
- declare readonly _serviceBrand: undefined;
- private readonly blobs = new Map();
-
- constructor(@IKaosFactory _kaosFactory: IKaosFactory) {}
-
- put(key: string, data: Uint8Array): Promise {
- this.blobs.set(key, data);
- return Promise.resolve();
- }
- get(key: string): Promise {
- return Promise.resolve(this.blobs.get(key));
- }
- delete(key: string): Promise {
- this.blobs.delete(key);
- return Promise.resolve();
- }
-}
-
-registerScopedService(LifecycleScope.Core, IFileStore, FileStore, InstantiationType.Delayed, 'filestore');
diff --git a/packages/agent-core-v2/src/filestore/filestore.ts b/packages/agent-core-v2/src/filestore/filestore.ts
deleted file mode 100644
index f5b1e7f9e..000000000
--- a/packages/agent-core-v2/src/filestore/filestore.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * `filestore` domain (cross-cutting) — core-scope blob/file store.
- *
- * Defines the public contract of the file store: the `IFileStore` used by
- * other domains to store and retrieve opaque blobs by key. Core-scoped — one
- * shared instance.
- */
-
-import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
-
-export interface IFileStore {
- readonly _serviceBrand: undefined;
- put(key: string, data: Uint8Array): Promise;
- get(key: string): Promise;
- delete(key: string): Promise;
-}
-
-export const IFileStore: ServiceIdentifier =
- createDecorator('fileStore');
diff --git a/packages/agent-core-v2/src/filestore/index.ts b/packages/agent-core-v2/src/filestore/index.ts
deleted file mode 100644
index c8ff6d2fb..000000000
--- a/packages/agent-core-v2/src/filestore/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * `filestore` domain barrel — re-exports the `filestore` contract and its
- * scoped service (`fileStoreService`). Importing this barrel registers the
- * `IFileStore` binding into the scope registry.
- */
-
-export * from './filestore';
-export * from './fileStoreService';
diff --git a/packages/agent-core-v2/src/fs/fs.ts b/packages/agent-core-v2/src/fs/fs.ts
deleted file mode 100644
index 9955cf932..000000000
--- a/packages/agent-core-v2/src/fs/fs.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-/**
- * `fs` domain (cross-cutting) — session-scope filesystem services.
- *
- * Defines the public contracts of filesystem access: the `IFsService`,
- * `IFsSearchService`, `IFsGitService`, and `IFsWatcher` used by tools to read
- * and write files, search, inspect git state, and watch paths. Session-scoped
- * — one set of services per session.
- */
-
-import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
-
-export interface IFsService {
- readonly _serviceBrand: undefined;
- read(path: string): Promise;
- write(path: string, content: string): Promise;
- stat(path: string): Promise;
- mkdir(path: string): Promise;
-}
-
-export const IFsService: ServiceIdentifier =
- createDecorator('fsService');
-
-export interface IFsSearchService {
- readonly _serviceBrand: undefined;
- grep(pattern: string, path: string): Promise;
- glob(pattern: string): Promise;
-}
-
-export const IFsSearchService: ServiceIdentifier =
- createDecorator('fsSearchService');
-
-export interface IFsGitService {
- readonly _serviceBrand: undefined;
- status(cwd: string): Promise;
- diff(cwd: string): Promise;
- log(cwd: string): Promise;
-}
-
-export const IFsGitService: ServiceIdentifier =
- createDecorator('fsGitService');
-
-export interface IFsWatcher {
- readonly _serviceBrand: undefined;
- watch(path: string): void;
- unwatch(path: string): void;
-}
-
-export const IFsWatcher: ServiceIdentifier =
- createDecorator('fsWatcher');
diff --git a/packages/agent-core-v2/src/fs/fsService.ts b/packages/agent-core-v2/src/fs/fsService.ts
deleted file mode 100644
index 6be6a0af6..000000000
--- a/packages/agent-core-v2/src/fs/fsService.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-/**
- * `fs` domain (cross-cutting) — `IFsService` / `IFsSearchService` /
- * `IFsGitService` / `IFsWatcher` implementation.
- *
- * Owns file I/O, search, git inspection, and path watching; accesses the
- * filesystem through `kaos` and logs through `log`. Bound at Session scope.
- */
-
-import type { Readable } from 'node:stream';
-
-import { Disposable } from '#/_base/di/lifecycle';
-import { InstantiationType } from '#/_base/di/extensions';
-import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import type { Kaos } from '@moonshot-ai/kaos';
-import { ISessionKaosService } from '#/kaos';
-import { ILogService } from '#/log';
-
-import {
- IFsGitService,
- IFsSearchService,
- IFsService,
- IFsWatcher,
-} from './fs';
-
-function readAll(stream: Readable): Promise {
- return new Promise((resolve, reject) => {
- let data = '';
- stream.setEncoding('utf8');
- stream.on('data', (chunk: string) => { data += chunk; });
- stream.on('end', () => resolve(data));
- stream.on('error', reject);
- });
-}
-
-export class FsService implements IFsService {
- declare readonly _serviceBrand: undefined;
-
- constructor(
- @ISessionKaosService private readonly sessionKaos: ISessionKaosService,
- @ILogService _log: ILogService,
- ) {}
-
- private get kaos(): Kaos {
- return this.sessionKaos.toolKaos;
- }
-
- read(path: string): Promise {
- return this.kaos.readText(path);
- }
- write(path: string, content: string): Promise {
- return this.kaos.writeText(path, content).then(() => undefined);
- }
- stat(path: string): Promise {
- return this.kaos.stat(path);
- }
- async mkdir(path: string): Promise {
- await this.kaos.mkdir(path, { parents: true, existOk: true });
- }
-}
-
-export class FsSearchService implements IFsSearchService {
- declare readonly _serviceBrand: undefined;
-
- constructor(
- @ISessionKaosService private readonly sessionKaos: ISessionKaosService,
- @ILogService _log: ILogService,
- ) {}
-
- private get kaos(): Kaos {
- return this.sessionKaos.toolKaos;
- }
-
- async grep(pattern: string, path: string): Promise {
- const proc = await this.kaos.exec('grep', '-r', '-n', pattern, path);
- const out = await readAll(proc.stdout);
- await proc.wait();
- return out.split('\n').filter((l) => l.length > 0);
- }
-
- async glob(pattern: string): Promise {
- const proc = await this.kaos.exec('find', '.', '-name', pattern);
- const out = await readAll(proc.stdout);
- await proc.wait();
- return out.split('\n').filter((l) => l.length > 0);
- }
-}
-
-export class FsGitService implements IFsGitService {
- declare readonly _serviceBrand: undefined;
-
- constructor(
- @ISessionKaosService private readonly sessionKaos: ISessionKaosService,
- @ILogService _log: ILogService,
- ) {}
-
- private get kaos(): Kaos {
- return this.sessionKaos.toolKaos;
- }
-
- private async git(...args: string[]): Promise {
- const proc = await this.kaos.exec('git', ...args);
- const out = await readAll(proc.stdout);
- await proc.wait();
- return out;
- }
-
- status(_cwd: string): Promise {
- return this.git('status', '--short');
- }
- diff(_cwd: string): Promise {
- return this.git('diff');
- }
- async log(_cwd: string): Promise {
- const out = await this.git('log', '--oneline', '-n', '20');
- return out.split('\n').filter((l) => l.length > 0);
- }
-}
-
-export class FsWatcher extends Disposable implements IFsWatcher {
- declare readonly _serviceBrand: undefined;
- private readonly watched = new Set();
-
- constructor(
- @ISessionKaosService _sessionKaos: ISessionKaosService,
- @ILogService _log: ILogService,
- ) {
- super();
- }
-
- watch(path: string): void {
- this.watched.add(path);
- }
- unwatch(path: string): void {
- this.watched.delete(path);
- }
-}
-
-registerScopedService(LifecycleScope.Session, IFsService, FsService, InstantiationType.Delayed, 'fs');
-registerScopedService(LifecycleScope.Session, IFsSearchService, FsSearchService, InstantiationType.Delayed, 'fs');
-registerScopedService(LifecycleScope.Session, IFsGitService, FsGitService, InstantiationType.Delayed, 'fs');
-registerScopedService(LifecycleScope.Session, IFsWatcher, FsWatcher, InstantiationType.Delayed, 'fs');
diff --git a/packages/agent-core-v2/src/fs/index.ts b/packages/agent-core-v2/src/fs/index.ts
deleted file mode 100644
index 442b55805..000000000
--- a/packages/agent-core-v2/src/fs/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * `fs` domain barrel — re-exports the filesystem contract (`fs`) and its scoped
- * services (`fsService`). Importing this barrel registers the `IFsService`,
- * `IFsSearchService`, and `IFsGitService` bindings into the scope registry.
- */
-
-export * from './fs';
-export * from './fsService';
diff --git a/packages/agent-core-v2/src/hostFolderBrowser/hostFolderBrowser.ts b/packages/agent-core-v2/src/hostFolderBrowser/hostFolderBrowser.ts
new file mode 100644
index 000000000..75a6c3f52
--- /dev/null
+++ b/packages/agent-core-v2/src/hostFolderBrowser/hostFolderBrowser.ts
@@ -0,0 +1,26 @@
+/**
+ * `hostFolderBrowser` domain (L1) — host-side folder picker.
+ *
+ * Defines the `IHostFolderBrowser` used by the program side (TUI / server) to
+ * let the user browse the real local filesystem when choosing a workspace
+ * folder. Distinct from the Agent-side `agentFs`, which is sandboxed and may
+ * be remote. Core-scoped.
+ */
+
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+import type { HostDirEntry } from '#/hostFs';
+
+export interface FsBrowseResponse {
+ readonly path: string;
+ readonly entries: readonly HostDirEntry[];
+}
+
+export interface IHostFolderBrowser {
+ readonly _serviceBrand: undefined;
+
+ browse(absPath?: string): Promise;
+ home(): Promise;
+}
+
+export const IHostFolderBrowser: ServiceIdentifier =
+ createDecorator('hostFolderBrowser');
diff --git a/packages/agent-core-v2/src/hostFolderBrowser/hostFolderBrowserService.ts b/packages/agent-core-v2/src/hostFolderBrowser/hostFolderBrowserService.ts
new file mode 100644
index 000000000..5c0a0b6d9
--- /dev/null
+++ b/packages/agent-core-v2/src/hostFolderBrowser/hostFolderBrowserService.ts
@@ -0,0 +1,39 @@
+/**
+ * `hostFolderBrowser` domain (L1) — `IHostFolderBrowser` implementation.
+ *
+ * Browses the real local filesystem through the program-side `hostFs`
+ * primitives. Bound at Core scope.
+ */
+
+import { homedir } from 'node:os';
+import { resolve } from 'node:path';
+
+import { InstantiationType } from '#/_base/di/extensions';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { IHostFileSystem } from '#/hostFs';
+
+import { type FsBrowseResponse, IHostFolderBrowser } from './hostFolderBrowser';
+
+export class HostFolderBrowser implements IHostFolderBrowser {
+ declare readonly _serviceBrand: undefined;
+
+ constructor(@IHostFileSystem private readonly hostFs: IHostFileSystem) {}
+
+ async browse(absPath?: string): Promise {
+ const path = resolve(absPath ?? homedir());
+ const entries = await this.hostFs.readdir(path);
+ return { path, entries };
+ }
+
+ home(): Promise {
+ return Promise.resolve(homedir());
+ }
+}
+
+registerScopedService(
+ LifecycleScope.Core,
+ IHostFolderBrowser,
+ HostFolderBrowser,
+ InstantiationType.Delayed,
+ 'hostFolderBrowser',
+);
diff --git a/packages/agent-core-v2/src/hostFolderBrowser/index.ts b/packages/agent-core-v2/src/hostFolderBrowser/index.ts
new file mode 100644
index 000000000..c0f38282a
--- /dev/null
+++ b/packages/agent-core-v2/src/hostFolderBrowser/index.ts
@@ -0,0 +1,9 @@
+/**
+ * `hostFolderBrowser` domain barrel — re-exports the host-folder-browser
+ * contract (`hostFolderBrowser`) and its scoped service
+ * (`hostFolderBrowserService`). Importing this barrel registers the
+ * `IHostFolderBrowser` binding into the scope registry.
+ */
+
+export * from './hostFolderBrowser';
+export * from './hostFolderBrowserService';
diff --git a/packages/agent-core-v2/src/hostFs/hostFs.ts b/packages/agent-core-v2/src/hostFs/hostFs.ts
new file mode 100644
index 000000000..28674df5e
--- /dev/null
+++ b/packages/agent-core-v2/src/hostFs/hostFs.ts
@@ -0,0 +1,39 @@
+/**
+ * `hostFs` domain (L1) — local real-filesystem primitives.
+ *
+ * Defines the `IHostFileSystem` used by the program side (persistence, skill
+ * loading, workspace registry) to read and write the app's own files on the
+ * real local disk, plus the stat/entry models. Core-scoped — one shared
+ * instance. The Agent side never injects this directly; its local backend
+ * reuses it internally.
+ */
+
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+
+export interface HostFileStat {
+ readonly isFile: boolean;
+ readonly isDirectory: boolean;
+ readonly size: number;
+}
+
+export interface HostDirEntry {
+ readonly name: string;
+ readonly isFile: boolean;
+ readonly isDirectory: boolean;
+}
+
+export interface IHostFileSystem {
+ readonly _serviceBrand: undefined;
+
+ readText(path: string): Promise;
+ writeText(path: string, data: string): Promise;
+ readBytes(path: string): Promise;
+ writeBytes(path: string, data: Uint8Array): Promise;
+ stat(path: string): Promise;
+ readdir(path: string): Promise;
+ mkdir(path: string, options?: { readonly recursive?: boolean }): Promise;
+ remove(path: string): Promise;
+}
+
+export const IHostFileSystem: ServiceIdentifier =
+ createDecorator('hostFileSystem');
diff --git a/packages/agent-core-v2/src/hostFs/hostFsService.ts b/packages/agent-core-v2/src/hostFs/hostFsService.ts
new file mode 100644
index 000000000..3df079c58
--- /dev/null
+++ b/packages/agent-core-v2/src/hostFs/hostFsService.ts
@@ -0,0 +1,64 @@
+/**
+ * `hostFs` domain (L1) — `IHostFileSystem` implementation.
+ *
+ * Reads and writes the app's own files on the real local disk through
+ * `node:fs/promises`. Bound at Core scope.
+ */
+
+import { readFile, readdir, stat, mkdir, rm, writeFile } from 'node:fs/promises';
+
+import { InstantiationType } from '#/_base/di/extensions';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+
+import { type HostDirEntry, type HostFileStat, IHostFileSystem } from './hostFs';
+
+export class HostFileSystem implements IHostFileSystem {
+ declare readonly _serviceBrand: undefined;
+
+ async readText(path: string): Promise {
+ return readFile(path, 'utf8');
+ }
+
+ async writeText(path: string, data: string): Promise {
+ await writeFile(path, data, 'utf8');
+ }
+
+ async readBytes(path: string): Promise {
+ const buf = await readFile(path);
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
+ }
+
+ async writeBytes(path: string, data: Uint8Array): Promise {
+ await writeFile(path, data);
+ }
+
+ async stat(path: string): Promise {
+ const s = await stat(path);
+ return { isFile: s.isFile(), isDirectory: s.isDirectory(), size: s.size };
+ }
+
+ async readdir(path: string): Promise {
+ const entries = await readdir(path, { withFileTypes: true });
+ return entries.map((d) => ({
+ name: d.name,
+ isFile: d.isFile(),
+ isDirectory: d.isDirectory(),
+ }));
+ }
+
+ async mkdir(path: string, options?: { readonly recursive?: boolean }): Promise {
+ await mkdir(path, { recursive: options?.recursive ?? false });
+ }
+
+ async remove(path: string): Promise {
+ await rm(path, { recursive: true, force: true });
+ }
+}
+
+registerScopedService(
+ LifecycleScope.Core,
+ IHostFileSystem,
+ HostFileSystem,
+ InstantiationType.Delayed,
+ 'hostFs',
+);
diff --git a/packages/agent-core-v2/src/hostFs/index.ts b/packages/agent-core-v2/src/hostFs/index.ts
new file mode 100644
index 000000000..24b22c394
--- /dev/null
+++ b/packages/agent-core-v2/src/hostFs/index.ts
@@ -0,0 +1,8 @@
+/**
+ * `hostFs` domain barrel — re-exports the host-filesystem contract
+ * (`hostFs`) and its scoped service (`hostFsService`). Importing this barrel
+ * registers the `IHostFileSystem` binding into the scope registry.
+ */
+
+export * from './hostFs';
+export * from './hostFsService';
diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts
index 71750db62..20b4e7018 100644
--- a/packages/agent-core-v2/src/index.ts
+++ b/packages/agent-core-v2/src/index.ts
@@ -9,7 +9,7 @@ export * from './errors';
export * from './log/index';
export * from './telemetry/index';
export * from './environment/index';
-export * from './kaos/index';
+export * from './hostFs/index';
export * from './kosong/index';
export * from './sessionStore/index';
@@ -42,10 +42,12 @@ import './approval/index';
export * from './question/index';
export * from './gateway/index';
-export * from './terminal/index';
-export * from './fs/index';
-export * from './workspace/index';
-export * from './filestore/index';
+export * from './workspaceContext/index';
+export * from './workspaceRegistry/index';
+export * from './hostFolderBrowser/index';
+export * from './agentFs/index';
+export * from './process/index';
+export * from './storage/index';
export * from './auth/index';
// Ported agent services. These keep the current service boundaries during the migration.
diff --git a/packages/agent-core-v2/src/kaos/agentKaos.ts b/packages/agent-core-v2/src/kaos/agentKaos.ts
deleted file mode 100644
index 2d07919ee..000000000
--- a/packages/agent-core-v2/src/kaos/agentKaos.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * `kaos` domain (L1) — `IKaosService` implementation.
- *
- * Exposes the agent's active `Kaos` instance and working directory, and
- * switches the working directory on `chdir`. Bound at Agent scope.
- */
-
-import type { Kaos } from '@moonshot-ai/kaos';
-
-import { InstantiationType } from '#/_base/di/extensions';
-import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-
-import { IKaosService, ISessionKaosService } from './kaos';
-
-export class AgentKaos implements IKaosService {
- declare readonly _serviceBrand: undefined;
- private _kaos: Kaos;
-
- constructor(@ISessionKaosService sessionKaos: ISessionKaosService) {
- this._kaos = sessionKaos.toolKaos;
- }
-
- get kaos(): Kaos {
- return this._kaos;
- }
-
- get cwd(): string {
- return this._kaos.getcwd();
- }
-
- chdir(cwd: string): Promise {
- this._kaos = this._kaos.withCwd(cwd);
- return Promise.resolve();
- }
-}
-
-registerScopedService(
- LifecycleScope.Agent,
- IKaosService,
- AgentKaos,
- InstantiationType.Delayed,
- 'kaos',
-);
diff --git a/packages/agent-core-v2/src/kaos/index.ts b/packages/agent-core-v2/src/kaos/index.ts
deleted file mode 100644
index 9b0562ce0..000000000
--- a/packages/agent-core-v2/src/kaos/index.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * `kaos` domain barrel — re-exports the `kaos` contract and its scoped
- * services (`kaosFactory`, `sessionKaosService`, `agentKaos`). Importing this
- * barrel registers the `IKaosFactory`, `ISessionKaosService`, and `IKaosService`
- * bindings into the scope registry.
- */
-
-export * from './kaos';
-export * from './kaosFactory';
-export * from './sessionKaosService';
-export * from './agentKaos';
diff --git a/packages/agent-core-v2/src/kaos/kaos.ts b/packages/agent-core-v2/src/kaos/kaos.ts
deleted file mode 100644
index bc48bbb34..000000000
--- a/packages/agent-core-v2/src/kaos/kaos.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import type { Kaos } from '@moonshot-ai/kaos';
-
-import { createDecorator } from '#/_base/di/instantiation';
-
-export interface IKaosService {
- readonly _serviceBrand: undefined;
- readonly kaos: Kaos | undefined;
- readonly cwd: string;
- chdir(cwd: string): Promise;
-}
-
-export const IKaosService = createDecorator('agentKaosService');
-
-export type KaosFactoryOptions =
- | { readonly kind: 'local'; readonly cwd?: string }
- | { readonly kind: 'ssh'; readonly host: string; readonly cwd?: string };
-
-export interface IKaosFactory {
- readonly _serviceBrand: undefined;
- create(options: KaosFactoryOptions): Promise;
-}
-
-export const IKaosFactory = createDecorator('kaosFactory');
-
-export interface ISessionKaosService {
- readonly _serviceBrand: undefined;
- readonly toolKaos: Kaos;
- readonly persistenceKaos: Kaos;
- readonly systemContextKaos: Kaos;
- readonly additionalDirs: readonly string[];
- setToolKaos(kaos: Kaos): void;
- setPersistenceKaos(kaos: Kaos): void;
- addAdditionalDir(dir: string): void;
- removeAdditionalDir(dir: string): void;
-}
-
-export const ISessionKaosService =
- createDecorator('sessionKaosService');
diff --git a/packages/agent-core-v2/src/kaos/kaosFactory.ts b/packages/agent-core-v2/src/kaos/kaosFactory.ts
deleted file mode 100644
index 43d30ae8c..000000000
--- a/packages/agent-core-v2/src/kaos/kaosFactory.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * `kaos` domain (L1) — `IKaosFactory` implementation.
- *
- * Creates `Kaos` instances for the requested kind; resolves paths through
- * `environment` and logs through `log`. Bound at Core scope.
- */
-
-import { type Kaos, LocalKaos } from '@moonshot-ai/kaos';
-
-import { InstantiationType } from '#/_base/di/extensions';
-import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import { IEnvironmentService } from '#/environment/environment';
-import { ILogService } from '#/log/log';
-
-import { type KaosFactoryOptions, IKaosFactory } from './kaos';
-
-export class KaosFactory implements IKaosFactory {
- declare readonly _serviceBrand: undefined;
-
- constructor(
- @IEnvironmentService _env: IEnvironmentService,
- @ILogService _log: ILogService,
- ) {}
-
- async create(options: KaosFactoryOptions): Promise {
- if (options.kind === 'ssh') {
- throw new Error('TODO: KaosFactory.create ssh');
- }
- const base = await LocalKaos.create();
- return options.cwd !== undefined ? base.withCwd(options.cwd) : base;
- }
-}
-
-registerScopedService(
- LifecycleScope.Core,
- IKaosFactory,
- KaosFactory,
- InstantiationType.Delayed,
- 'kaos',
-);
diff --git a/packages/agent-core-v2/src/kaos/kaosService.ts b/packages/agent-core-v2/src/kaos/kaosService.ts
deleted file mode 100644
index 6064b6d2f..000000000
--- a/packages/agent-core-v2/src/kaos/kaosService.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { Kaos } from '@moonshot-ai/kaos';
-import { InstantiationType } from '#/_base/di/extensions';
-import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-
-import { IKaosService } from './kaos';
-
-export interface KaosServiceOptions {
- readonly kaos?: Kaos;
-}
-
-export class KaosService implements IKaosService {
- declare readonly _serviceBrand: undefined;
-
- constructor(private readonly options: KaosServiceOptions = {}) {}
-
- get kaos(): Kaos | undefined {
- return this.options.kaos;
- }
-
- get cwd(): string {
- const kaos = this.options.kaos;
- if (kaos === undefined) {
- throw new Error('KaosService.cwd accessed before kaos was provided');
- }
- return kaos.getcwd();
- }
-
- chdir(): Promise {
- return Promise.reject(new Error('KaosService.chdir is not supported'));
- }
-}
-
-registerScopedService(
- LifecycleScope.Agent,
- IKaosService,
- KaosService,
- InstantiationType.Delayed,
- 'kaos',
-);
diff --git a/packages/agent-core-v2/src/kaos/sessionKaosService.ts b/packages/agent-core-v2/src/kaos/sessionKaosService.ts
deleted file mode 100644
index 6c5571f14..000000000
--- a/packages/agent-core-v2/src/kaos/sessionKaosService.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * `kaos` domain (L1) — `ISessionKaosService` implementation.
- *
- * Holds the session's tool, persistence, and system-context `Kaos`
- * environments plus additional search directories; logs through `log`. Bound
- * at Session scope.
- */
-
-import type { Kaos } from '@moonshot-ai/kaos';
-
-import { Disposable } from '#/_base/di/lifecycle';
-import { InstantiationType } from '#/_base/di/extensions';
-import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import { ILogService } from '#/log/log';
-
-import { ISessionKaosService } from './kaos';
-
-export class SessionKaosService extends Disposable implements ISessionKaosService {
- declare readonly _serviceBrand: undefined;
- private _toolKaos: Kaos | undefined;
- private _persistenceKaos: Kaos | undefined;
- private _additionalDirs: string[] = [];
-
- constructor(@ILogService _log: ILogService) {
- super();
- }
-
- get toolKaos(): Kaos {
- if (this._toolKaos === undefined) {
- throw new Error('SessionKaosService.toolKaos accessed before setToolKaos');
- }
- return this._toolKaos;
- }
-
- get persistenceKaos(): Kaos {
- return this._persistenceKaos ?? this.toolKaos;
- }
-
- get systemContextKaos(): Kaos {
- return this.persistenceKaos.withCwd(this.toolKaos.getcwd());
- }
-
- get additionalDirs(): readonly string[] {
- return this._additionalDirs;
- }
-
- setToolKaos(kaos: Kaos): void {
- this._toolKaos = kaos;
- if (this._persistenceKaos === undefined) {
- this._persistenceKaos = kaos;
- }
- }
-
- setPersistenceKaos(kaos: Kaos): void {
- this._persistenceKaos = kaos;
- }
-
- addAdditionalDir(dir: string): void {
- if (!this._additionalDirs.includes(dir)) {
- this._additionalDirs.push(dir);
- }
- }
-
- removeAdditionalDir(dir: string): void {
- this._additionalDirs = this._additionalDirs.filter((d) => d !== dir);
- }
-}
-
-registerScopedService(
- LifecycleScope.Session,
- ISessionKaosService,
- SessionKaosService,
- InstantiationType.Delayed,
- 'kaos',
-);
diff --git a/packages/agent-core-v2/src/plan/planService.ts b/packages/agent-core-v2/src/plan/planService.ts
index d1b64470b..7fdbcad29 100644
--- a/packages/agent-core-v2/src/plan/planService.ts
+++ b/packages/agent-core-v2/src/plan/planService.ts
@@ -16,7 +16,7 @@ import { generateHeroSlug } from "#/_base/utils/hero-slug";
import { IContextMemory, type ContextMessage } from '#/contextMemory';
import { IContextInjector } from '../contextInjector';
import { IEventSink } from '../eventSink';
-import { IKaosService } from '#/kaos';
+import { IAgentFileSystem } from '#/agentFs';
import { IProfileService } from '#/profile';
import { IReplayBuilderService } from '#/replayBuilder';
import { ITelemetryService } from '#/telemetry';
@@ -53,7 +53,7 @@ export class PlanService extends Disposable implements IPlanService {
@IContextMemory private readonly context: IContextMemory,
@IWireRecord private readonly wireRecord: IWireRecord,
@IEventSink private readonly events: IEventSink,
- @IKaosService private readonly kaosService: IKaosService,
+ @IAgentFileSystem private readonly agentFs: IAgentFileSystem,
@IProfileService private readonly profile: IProfileService,
@IReplayBuilderService private readonly replayBuilder: IReplayBuilderService,
@IToolRegistry toolRegistry: IToolRegistry,
@@ -200,11 +200,9 @@ export class PlanService extends Disposable implements IPlanService {
async status(): Promise {
if (this.planId === null || this._planFilePath === null) return null;
- const kaos = this.kaosService.kaos;
- if (kaos === undefined) return null;
let content = '';
try {
- content = await kaos.readText(this._planFilePath);
+ content = await this.agentFs.readText(this._planFilePath);
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
@@ -362,14 +360,11 @@ export class PlanService extends Disposable implements IPlanService {
private async writeEmptyPlanFile(path: string): Promise {
await this.ensurePlanDirectory(path);
- await this.kaosService.kaos?.writeText(path, '');
+ await this.agentFs.writeText(path, '');
}
private async ensurePlanDirectory(path: string): Promise {
- await this.kaosService.kaos?.mkdir(dirname(path), {
- parents: true,
- existOk: true,
- });
+ await this.agentFs.mkdir(dirname(path));
}
private currentCwd(): string {
diff --git a/packages/agent-core-v2/src/process/index.ts b/packages/agent-core-v2/src/process/index.ts
new file mode 100644
index 000000000..3b8ab1f1e
--- /dev/null
+++ b/packages/agent-core-v2/src/process/index.ts
@@ -0,0 +1,12 @@
+/**
+ * `process` domain barrel — re-exports the process contract (`process`), its
+ * scoped service (`processRunnerService`), and the backend implementations
+ * (`localProcessBackend`, `sshProcessBackend`). Importing this barrel
+ * registers the `IProcessRunner` and default local `IProcessBackend` bindings
+ * into the scope registry.
+ */
+
+export * from './process';
+export * from './processRunnerService';
+export * from './localProcessBackend';
+export * from './sshProcessBackend';
diff --git a/packages/agent-core-v2/src/process/localProcessBackend.ts b/packages/agent-core-v2/src/process/localProcessBackend.ts
new file mode 100644
index 000000000..2727e2dcb
--- /dev/null
+++ b/packages/agent-core-v2/src/process/localProcessBackend.ts
@@ -0,0 +1,72 @@
+/**
+ * `process` domain (L1) — local `IProcessBackend` implementation.
+ *
+ * Spawns real child processes on the host through `node:child_process`.
+ * Registered as the default `IProcessBackend` at Session scope; remote
+ * backends override it via the scope registry.
+ */
+
+import { type ChildProcess, spawn } from 'node:child_process';
+import type { Readable, Writable } from 'node:stream';
+
+import { InstantiationType } from '#/_base/di/extensions';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+
+import { type IProcess, IProcessBackend } from './process';
+
+class LocalProcess implements IProcess {
+ readonly stdin: Writable;
+ readonly stdout: Readable;
+ readonly stderr: Readable;
+ readonly pid: number;
+
+ constructor(private readonly child: ChildProcess) {
+ if (child.stdin === null || child.stdout === null || child.stderr === null) {
+ throw new Error('LocalProcess: child must be spawned with piped stdio.');
+ }
+ this.stdin = child.stdin;
+ this.stdout = child.stdout;
+ this.stderr = child.stderr;
+ this.pid = child.pid ?? -1;
+ }
+
+ wait(): Promise {
+ return new Promise((resolve, reject) => {
+ this.child.once('exit', (code) => resolve(code ?? -1));
+ this.child.once('error', reject);
+ });
+ }
+
+ kill(signal?: NodeJS.Signals): Promise {
+ this.child.kill(signal);
+ return Promise.resolve();
+ }
+}
+
+export class LocalProcessBackend implements IProcessBackend {
+ declare readonly _serviceBrand: undefined;
+
+ spawn(
+ args: readonly string[],
+ options: { readonly cwd: string; readonly env?: Record },
+ ): Promise {
+ const [command, ...rest] = args;
+ if (command === undefined) {
+ return Promise.reject(new Error('LocalProcessBackend.spawn: command is required.'));
+ }
+ const child = spawn(command, rest, {
+ cwd: options.cwd,
+ env: options.env === undefined ? process.env : { ...process.env, ...options.env },
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ return Promise.resolve(new LocalProcess(child));
+ }
+}
+
+registerScopedService(
+ LifecycleScope.Session,
+ IProcessBackend,
+ LocalProcessBackend,
+ InstantiationType.Delayed,
+ 'process',
+);
diff --git a/packages/agent-core-v2/src/process/process.ts b/packages/agent-core-v2/src/process/process.ts
new file mode 100644
index 000000000..8e5470e40
--- /dev/null
+++ b/packages/agent-core-v2/src/process/process.ts
@@ -0,0 +1,48 @@
+/**
+ * `process` domain (L1) — the Agent's process runner and its pluggable backend.
+ *
+ * Defines the `IProcessRunner` that business code injects to spawn processes
+ * inside the Agent's execution environment, the `IProcess` handle it returns,
+ * and the internal `IProcessBackend` provider that hides the
+ * local/ssh/container split. Session-scoped. Business code depends on
+ * `IProcessRunner` only; the backend is wired through the scope registry.
+ */
+
+import type { Readable, Writable } from 'node:stream';
+
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+
+export interface IProcess {
+ readonly stdin: Writable;
+ readonly stdout: Readable;
+ readonly stderr: Readable;
+ readonly pid: number;
+ wait(): Promise;
+ kill(signal?: NodeJS.Signals): Promise;
+}
+
+export interface ProcessExecOptions {
+ readonly cwd?: string;
+ readonly env?: Record;
+}
+
+export interface IProcessRunner {
+ readonly _serviceBrand: undefined;
+
+ exec(args: readonly string[], options?: ProcessExecOptions): Promise;
+}
+
+export const IProcessRunner: ServiceIdentifier =
+ createDecorator('processRunner');
+
+export interface IProcessBackend {
+ readonly _serviceBrand: undefined;
+
+ spawn(
+ args: readonly string[],
+ options: { readonly cwd: string; readonly env?: Record },
+ ): Promise;
+}
+
+export const IProcessBackend: ServiceIdentifier =
+ createDecorator('processBackend');
diff --git a/packages/agent-core-v2/src/process/processRunnerService.ts b/packages/agent-core-v2/src/process/processRunnerService.ts
new file mode 100644
index 000000000..375e95514
--- /dev/null
+++ b/packages/agent-core-v2/src/process/processRunnerService.ts
@@ -0,0 +1,41 @@
+/**
+ * `process` domain (L1) — `IProcessRunner` implementation.
+ *
+ * Resolves the working directory through `workspaceContext` and delegates
+ * spawning to the injected `IProcessBackend`. Bound at Session scope.
+ */
+
+import { InstantiationType } from '#/_base/di/extensions';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { IWorkspaceContext } from '#/workspaceContext';
+
+import {
+ type IProcess,
+ IProcessBackend,
+ IProcessRunner,
+ type ProcessExecOptions,
+} from './process';
+
+export class ProcessRunner implements IProcessRunner {
+ declare readonly _serviceBrand: undefined;
+
+ constructor(
+ @IProcessBackend private readonly backend: IProcessBackend,
+ @IWorkspaceContext private readonly workspace: IWorkspaceContext,
+ ) {}
+
+ exec(args: readonly string[], options?: ProcessExecOptions): Promise {
+ return this.backend.spawn(args, {
+ cwd: options?.cwd ?? this.workspace.workDir,
+ env: options?.env,
+ });
+ }
+}
+
+registerScopedService(
+ LifecycleScope.Session,
+ IProcessRunner,
+ ProcessRunner,
+ InstantiationType.Delayed,
+ 'process',
+);
diff --git a/packages/agent-core-v2/src/process/sshProcessBackend.ts b/packages/agent-core-v2/src/process/sshProcessBackend.ts
new file mode 100644
index 000000000..063c18dce
--- /dev/null
+++ b/packages/agent-core-v2/src/process/sshProcessBackend.ts
@@ -0,0 +1,22 @@
+/**
+ * `process` domain (L1) — ssh `IProcessBackend` stub.
+ *
+ * Placeholder for the remote backend; not registered into the scope registry
+ * yet. A composition root that needs ssh supplies it through
+ * `ScopeOptions.extra` to override the local backend.
+ */
+
+import { NotImplementedError } from '#/_base/errors';
+
+import { type IProcess, IProcessBackend } from './process';
+
+export class SshProcessBackend implements IProcessBackend {
+ declare readonly _serviceBrand: undefined;
+
+ spawn(
+ _args: readonly string[],
+ _options: { readonly cwd: string; readonly env?: Record },
+ ): Promise {
+ throw new NotImplementedError('sshProcessBackend');
+ }
+}
diff --git a/packages/agent-core-v2/src/sessionMetaStore/sessionMetaStoreService.ts b/packages/agent-core-v2/src/sessionMetaStore/sessionMetaStoreService.ts
index 283232d08..325fcbc24 100644
--- a/packages/agent-core-v2/src/sessionMetaStore/sessionMetaStoreService.ts
+++ b/packages/agent-core-v2/src/sessionMetaStore/sessionMetaStoreService.ts
@@ -1,36 +1,37 @@
/**
- * `sessionMetaStore` — session-scope metadata persistence.
+ * `sessionMetaStore` domain (L2) — `ISessionMetaStore` implementation.
+ *
+ * Persists session metadata as a single atomic document through the program
+ * side `storage` (`IConfigStore`). Bound at Session scope.
*/
import { Disposable } from '#/_base/di/lifecycle';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import { ISessionKaosService } from '#/kaos';
import { ILogService } from '#/log';
+import { IConfigStore } from '#/storage';
import { ISessionMetaStore } from './sessionMetaStore';
+const SCOPE = 'session-meta';
+
export class SessionMetaStore extends Disposable implements ISessionMetaStore {
declare readonly _serviceBrand: undefined;
private data: Record = {};
- private readonly path: string;
+ private readonly key: string;
constructor(
- @ISessionKaosService private readonly sessionKaos: ISessionKaosService,
+ @IConfigStore private readonly configStore: IConfigStore,
@ILogService _log: ILogService,
- path: string = 'state.json',
+ key: string = 'state.json',
) {
super();
- this.path = path;
+ this.key = key;
}
async read(): Promise> {
- try {
- const text = await this.sessionKaos.persistenceKaos.readText(this.path);
- this.data = JSON.parse(text) as Record;
- } catch {
- this.data = {};
- }
+ this.data =
+ (await this.configStore.get>(SCOPE, this.key)) ?? {};
return this.data;
}
@@ -40,10 +41,7 @@ export class SessionMetaStore extends Disposable implements ISessionMetaStore {
}
async flush(): Promise {
- await this.sessionKaos.persistenceKaos.writeText(
- this.path,
- JSON.stringify(this.data, null, 2),
- );
+ await this.configStore.set(SCOPE, this.key, this.data);
}
}
diff --git a/packages/agent-core-v2/src/sessionStore/sessionStoreService.ts b/packages/agent-core-v2/src/sessionStore/sessionStoreService.ts
index 14d389bfd..ba83ebd53 100644
--- a/packages/agent-core-v2/src/sessionStore/sessionStoreService.ts
+++ b/packages/agent-core-v2/src/sessionStore/sessionStoreService.ts
@@ -1,15 +1,17 @@
/**
- * `sessionStore` — core-scope session directory store.
+ * `sessionStore` domain (L2) — `ISessionStore` implementation.
+ *
+ * Enumerates session directories on the real local disk through the program
+ * side `hostFs` primitives. Bound at Core scope.
*/
import { createHash } from 'node:crypto';
-import { promises as fsp } from 'node:fs';
import { join } from 'node:path';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { slugifyWorkDirName } from '#/_base/utils/workdir-slug';
-import { IKaosFactory } from '#/kaos';
+import { IHostFileSystem } from '#/hostFs';
import { ISessionStore } from './sessionStore';
@@ -26,7 +28,8 @@ export function encodeWorkDirKey(workDir: string): string {
export class SessionStore implements ISessionStore {
declare readonly _serviceBrand: undefined;
- constructor(@IKaosFactory _kaosFactory: IKaosFactory) {}
+
+ constructor(@IHostFileSystem private readonly hostFs: IHostFileSystem) {}
sessionDir(sessionsRoot: string, workDir: string, sessionId: string): string {
return `${sessionsRoot}/${encodeWorkDirKey(workDir)}/${sessionId}`;
@@ -38,37 +41,34 @@ export class SessionStore implements ISessionStore {
async countActiveSessions(sessionsRoot: string, workDir: string): Promise {
const dir = join(sessionsRoot, encodeWorkDirKey(workDir));
- let dirents;
+ let entries;
try {
- dirents = await fsp.readdir(dir, { withFileTypes: true });
+ entries = await this.hostFs.readdir(dir);
} catch (err) {
- const code = (err as NodeJS.ErrnoException).code;
- if (code === 'ENOENT') return 0;
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0;
throw err;
}
let count = 0;
- for (const d of dirents) {
- if (!d.isDirectory()) continue;
- if (await isSessionArchived(join(dir, d.name))) continue;
+ for (const entry of entries) {
+ if (!entry.isDirectory) continue;
+ if (await this.isSessionArchived(join(dir, entry.name))) continue;
count += 1;
}
return count;
}
-}
-async function isSessionArchived(sessionDir: string): Promise {
- try {
- const raw = await fsp.readFile(join(sessionDir, 'state.json'), 'utf8');
- const parsed = JSON.parse(raw) as unknown;
- return (
- typeof parsed === 'object' &&
- parsed !== null &&
- (parsed as { archived?: boolean }).archived === true
- );
- } catch {
- // Treat unreadable/missing state.json as non-archived so the directory still
- // counts as a session (matches the session store's own loading behavior).
- return false;
+ private async isSessionArchived(sessionDir: string): Promise {
+ try {
+ const raw = await this.hostFs.readText(join(sessionDir, 'state.json'));
+ const parsed = JSON.parse(raw) as unknown;
+ return (
+ typeof parsed === 'object' &&
+ parsed !== null &&
+ (parsed as { archived?: boolean }).archived === true
+ );
+ } catch {
+ return false;
+ }
}
}
diff --git a/packages/agent-core-v2/src/storage/configStore.ts b/packages/agent-core-v2/src/storage/configStore.ts
new file mode 100644
index 000000000..d96a8ab1d
--- /dev/null
+++ b/packages/agent-core-v2/src/storage/configStore.ts
@@ -0,0 +1,74 @@
+/**
+ * `IConfigStore` / `ConfigStore` — the typed atomic-document service.
+ *
+ * Sits on top of `IStorageService` and stores one typed JSON value per
+ * `(scope, key)`, replaced atomically on every write. This is the `Config`
+ * access pattern: `state.json`, `upcoming-goals.json`, per-id cron/background
+ * records, etc.
+ *
+ * It is a DI service: domains inject `IConfigStore` and call `get/set` with
+ * the scope they own — they do not construct stores themselves. JSON
+ * (de)serialization and atomic replacement are centralized here so domains
+ * do not reimplement them.
+ */
+
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+import { InstantiationType } from '#/_base/di/extensions';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+
+import { IStorageService } from './storageService';
+
+const textEncoder = new TextEncoder();
+const textDecoder = new TextDecoder();
+
+export interface IConfigStore {
+ readonly _serviceBrand: undefined;
+
+ /** Read the value at `(scope, key)`, or `undefined` when absent. */
+ get(scope: string, key: string): Promise;
+
+ /** Atomically replace the value at `(scope, key)`. */
+ set(scope: string, key: string, value: T): Promise;
+
+ /** Delete `(scope, key)`. Missing keys are not an error. */
+ delete(scope: string, key: string): Promise;
+
+ /** List the keys under `scope`, optionally filtered by `prefix`. */
+ list(scope: string, prefix?: string): Promise;
+}
+
+export const IConfigStore: ServiceIdentifier =
+ createDecorator('configStore');
+
+export class ConfigStore implements IConfigStore {
+ declare readonly _serviceBrand: undefined;
+
+ constructor(@IStorageService private readonly storage: IStorageService) {}
+
+ async get(scope: string, key: string): Promise {
+ const bytes = await this.storage.read(scope, key);
+ return bytes === undefined ? undefined : (JSON.parse(textDecoder.decode(bytes)) as T);
+ }
+
+ async set(scope: string, key: string, value: T): Promise {
+ await this.storage.write(scope, key, textEncoder.encode(JSON.stringify(value)), {
+ atomic: true,
+ });
+ }
+
+ async delete(scope: string, key: string): Promise {
+ await this.storage.delete(scope, key);
+ }
+
+ async list(scope: string, prefix?: string): Promise {
+ return this.storage.list(scope, prefix);
+ }
+}
+
+registerScopedService(
+ LifecycleScope.Session,
+ IConfigStore,
+ ConfigStore,
+ InstantiationType.Delayed,
+ 'storage',
+);
diff --git a/packages/agent-core-v2/src/storage/fileStorageService.ts b/packages/agent-core-v2/src/storage/fileStorageService.ts
new file mode 100644
index 000000000..11c34dbd8
--- /dev/null
+++ b/packages/agent-core-v2/src/storage/fileStorageService.ts
@@ -0,0 +1,125 @@
+/**
+ * `FileStorageService` — `IStorageService` backed by the local filesystem.
+ *
+ * Layout: a value addressed by `(scope, key)` lives at
+ * `//`. `scope` may contain slashes to form nested
+ * directories (e.g. `"agents/main"`).
+ *
+ * Primitives:
+ * - `write` → `atomicWrite` (tmp + fsync + rename) followed by a directory
+ * fsync, so the replacement is both atomic and durable.
+ * - `append` → `open('a')` + write + `fh.sync()` (when `durable`), plus a
+ * one-time directory fsync per scope.
+ *
+ * It uses raw `node:fs` (like `wireRecord/persistence.ts` and
+ * `blobStoreService.ts`) rather than `kaos`: the storage kernel needs direct
+ * control over append offsets, fsync, atomic rename and (future) streaming,
+ * which the agent-execution-environment abstraction does not expose.
+ */
+
+import { mkdir, open, readFile, readdir, unlink } from 'node:fs/promises';
+import { dirname, join } from 'pathe';
+
+import { atomicWrite, syncDir } from '#/_base/utils/fs';
+
+import type {
+ IStorageService,
+ StorageAppendOptions,
+ StorageWriteOptions,
+} from './storageService';
+
+function isEnoent(error: unknown): boolean {
+ return (error as NodeJS.ErrnoException).code === 'ENOENT';
+}
+
+export class FileStorageService implements IStorageService {
+ declare readonly _serviceBrand: undefined;
+
+ private readonly syncedDirs = new Set();
+
+ constructor(private readonly baseDir: string) {}
+
+ async read(scope: string, key: string): Promise {
+ try {
+ return await readFile(this.path(scope, key));
+ } catch (error) {
+ if (isEnoent(error)) return undefined;
+ throw error;
+ }
+ }
+
+ async write(
+ scope: string,
+ key: string,
+ data: Uint8Array,
+ _options: StorageWriteOptions = {},
+ ): Promise {
+ const filePath = this.path(scope, key);
+ await mkdir(dirname(filePath), { recursive: true });
+ await atomicWrite(filePath, data);
+ await this.syncDirOnce(dirname(filePath));
+ }
+
+ async append(
+ scope: string,
+ key: string,
+ data: Uint8Array,
+ options: StorageAppendOptions = {},
+ ): Promise {
+ const filePath = this.path(scope, key);
+ const dir = dirname(filePath);
+ await mkdir(dir, { recursive: true });
+
+ const fh = await open(filePath, 'a');
+ try {
+ if (data.byteLength > 0) {
+ await fh.writeFile(data);
+ }
+ if (options.durable !== false) {
+ await fh.sync();
+ }
+ } finally {
+ await fh.close();
+ }
+ await this.syncDirOnce(dir);
+ }
+
+ async list(scope: string, prefix?: string): Promise {
+ let entries: readonly string[];
+ try {
+ entries = await readdir(this.scopePath(scope));
+ } catch (error) {
+ if (isEnoent(error)) return [];
+ throw error;
+ }
+ return prefix === undefined ? entries : entries.filter((entry) => entry.startsWith(prefix));
+ }
+
+ async delete(scope: string, key: string): Promise {
+ try {
+ await unlink(this.path(scope, key));
+ } catch (error) {
+ if (!isEnoent(error)) throw error;
+ }
+ }
+
+ async flush(): Promise {
+ // Writes resolve only after the bytes are durable; nothing is buffered.
+ }
+
+ async close(): Promise {}
+
+ private path(scope: string, key: string): string {
+ return join(this.baseDir, scope, key);
+ }
+
+ private scopePath(scope: string): string {
+ return join(this.baseDir, scope);
+ }
+
+ private async syncDirOnce(dir: string): Promise {
+ if (this.syncedDirs.has(dir)) return;
+ await syncDir(dir);
+ this.syncedDirs.add(dir);
+ }
+}
diff --git a/packages/agent-core-v2/src/storage/inMemoryStorageService.ts b/packages/agent-core-v2/src/storage/inMemoryStorageService.ts
new file mode 100644
index 000000000..3c351d817
--- /dev/null
+++ b/packages/agent-core-v2/src/storage/inMemoryStorageService.ts
@@ -0,0 +1,91 @@
+/**
+ * `InMemoryStorageService` — `IStorageService` backed by in-memory maps.
+ *
+ * Registered as the default `IStorageService` so scopes and tests work out of
+ * the box. For durable production storage, the composition root seeds a
+ * `FileStorageService` (rooted at the session directory) into the scope via
+ * `ScopeOptions.extra`, overriding this default — the same pattern
+ * `blobStoreService` uses.
+ *
+ * `append` concatenates into the same key slot `write` replaces, mirroring the
+ * file implementation's single-namespace semantics so the two are
+ * interchangeable for the facades above.
+ */
+
+import { InstantiationType } from '#/_base/di/extensions';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+
+import {
+ IStorageService,
+ type StorageAppendOptions,
+ type StorageWriteOptions,
+} from './storageService';
+
+export class InMemoryStorageService implements IStorageService {
+ declare readonly _serviceBrand: undefined;
+
+ private readonly scopes = new Map>();
+
+ async read(scope: string, key: string): Promise {
+ return this.scopes.get(scope)?.get(key);
+ }
+
+ async write(
+ scope: string,
+ key: string,
+ data: Uint8Array,
+ _options: StorageWriteOptions = {},
+ ): Promise {
+ this.bucket(scope).set(key, data);
+ }
+
+ async append(
+ scope: string,
+ key: string,
+ data: Uint8Array,
+ _options: StorageAppendOptions = {},
+ ): Promise {
+ const bucket = this.bucket(scope);
+ const existing = bucket.get(key);
+ if (existing === undefined) {
+ bucket.set(key, data);
+ return;
+ }
+ const merged = new Uint8Array(existing.byteLength + data.byteLength);
+ merged.set(existing, 0);
+ merged.set(data, existing.byteLength);
+ bucket.set(key, merged);
+ }
+
+ async list(scope: string, prefix?: string): Promise {
+ const bucket = this.scopes.get(scope);
+ if (bucket === undefined) return [];
+ const keys = [...bucket.keys()];
+ return prefix === undefined ? keys : keys.filter((key) => key.startsWith(prefix));
+ }
+
+ async delete(scope: string, key: string): Promise {
+ this.scopes.get(scope)?.delete(key);
+ }
+
+ async flush(): Promise {}
+
+ async close(): Promise {}
+
+ private bucket(scope: string): Map {
+ let bucket = this.scopes.get(scope);
+ if (bucket === undefined) {
+ bucket = new Map();
+ this.scopes.set(scope, bucket);
+ }
+ return bucket;
+ }
+}
+
+registerScopedService(
+ LifecycleScope.Session,
+ IStorageService,
+ InMemoryStorageService,
+ InstantiationType.Delayed,
+ 'storage',
+);
diff --git a/packages/agent-core-v2/src/storage/index.ts b/packages/agent-core-v2/src/storage/index.ts
new file mode 100644
index 000000000..10e101d40
--- /dev/null
+++ b/packages/agent-core-v2/src/storage/index.ts
@@ -0,0 +1,6 @@
+export * from './storageService';
+export * from './fileStorageService';
+export * from './inMemoryStorageService';
+export * from './recordStore';
+export * from './configStore';
+export * from './queryStore';
diff --git a/packages/agent-core-v2/src/storage/queryStore.ts b/packages/agent-core-v2/src/storage/queryStore.ts
new file mode 100644
index 000000000..7bf04802a
--- /dev/null
+++ b/packages/agent-core-v2/src/storage/queryStore.ts
@@ -0,0 +1,114 @@
+/**
+ * `IQueryStore` — the indexed, queryable read-model facade.
+ *
+ * A peer of `IRecordStore` and `IConfigStore`. Where `IRecordStore` is the
+ * authoritative append-only write model and `IConfigStore` holds atomic
+ * documents, `IQueryStore` serves fast, indexed, paginated reads over a
+ * *derived* dataset — typically materialized from a record log by a projector.
+ *
+ * This file intentionally ships the interface only. A concrete implementation
+ * (e.g. backed by `minidb`) and the projector that feeds it are a follow-up;
+ * the contract is fixed here so domains can depend on it without coupling to
+ * any specific engine.
+ *
+ * `collection` is a logical table (an engine may encode it as a key prefix).
+ * Values are plain JSON-shaped objects; indexes are declared over their fields.
+ */
+
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+
+export type SortDir = 'asc' | 'desc';
+
+export interface Page {
+ readonly items: readonly T[];
+ /** Opaque token for the next page, or `undefined` when exhausted. */
+ readonly nextCursor?: string;
+}
+
+export interface ComparisonOp {
+ readonly $eq?: unknown;
+ readonly $ne?: unknown;
+ readonly $gt?: number | string;
+ readonly $gte?: number | string;
+ readonly $lt?: number | string;
+ readonly $lte?: number | string;
+ readonly $in?: readonly unknown[];
+ readonly $nin?: readonly unknown[];
+ readonly $exists?: boolean;
+}
+
+/** A field mapped to a scalar (shorthand for `$eq`) or a set of operators. */
+export type QueryFilter = {
+ readonly [field: string]: unknown | ComparisonOp;
+};
+
+export interface IQuery {
+ where(filter: QueryFilter): IQuery;
+ orderBy(field: string, dir?: SortDir): IQuery;
+ limit(n: number): IQuery;
+ cursor(cursor: string | undefined): IQuery;
+ execute(): Promise>;
+}
+
+export interface ValueIndexDef {
+ readonly kind: 'value';
+ readonly name: string;
+ /** Dot/bracket path into the value, e.g. `"model"` or `"meta.user"`. */
+ readonly field: string;
+ readonly unique?: boolean;
+}
+
+export interface CompoundIndexDef {
+ readonly kind: 'compound';
+ readonly name: string;
+ readonly groupBy: string;
+ readonly orderBy: string;
+}
+
+export interface TextIndexDef {
+ readonly kind: 'text';
+ readonly name: string;
+ /** Fields to tokenize; empty/omitted means the whole value. */
+ readonly fields?: readonly string[];
+}
+
+export type IndexDef = ValueIndexDef | CompoundIndexDef | TextIndexDef;
+
+export type WriteOp =
+ | { readonly kind: 'put'; readonly collection: string; readonly key: string; readonly value: unknown }
+ | { readonly kind: 'delete'; readonly collection: string; readonly key: string };
+
+/** Position a projector has reached in its source log. */
+export interface Checkpoint {
+ readonly seq: number;
+}
+
+export interface IQueryStore {
+ readonly _serviceBrand: undefined;
+
+ /** Upsert a value (projector write path). */
+ put(collection: string, key: string, value: T): Promise;
+
+ /** Apply several writes atomically. */
+ batch(ops: readonly WriteOp[]): Promise;
+
+ delete(collection: string, key: string): Promise;
+
+ get(collection: string, key: string): Promise;
+
+ /** Start a query against a collection (read path). */
+ query(collection: string): IQuery;
+
+ /** Declare an index. Idempotent — re-declaring an existing index is a no-op. */
+ ensureIndex(collection: string, def: IndexDef): Promise;
+
+ /** Read how far a projector has indexed a given source log. */
+ getCheckpoint(source: string): Promise;
+
+ /** Persist a projector's progress for a given source log. */
+ setCheckpoint(source: string, checkpoint: Checkpoint): Promise;
+
+ close(): Promise;
+}
+
+export const IQueryStore: ServiceIdentifier = createDecorator('queryStore');
diff --git a/packages/agent-core-v2/src/storage/recordStore.ts b/packages/agent-core-v2/src/storage/recordStore.ts
new file mode 100644
index 000000000..bd80c8dc0
--- /dev/null
+++ b/packages/agent-core-v2/src/storage/recordStore.ts
@@ -0,0 +1,190 @@
+/**
+ * `IRecordStore` / `RecordStore` — the typed append-log service.
+ *
+ * Sits on top of `IStorageService` and turns a byte stream into an ordered
+ * sequence of typed JSON records. Owns the concerns the storage service
+ * deliberately ignores: line framing (one JSON value per line, a.k.a. JSONL),
+ * batching of appends into a single durable `append`, and crash-tolerant
+ * decoding (a torn final line is dropped; corruption anywhere else throws).
+ *
+ * It is a DI service: domains inject `IRecordStore` and call
+ * `append/read/rewrite` with the `(scope, key)` of the log they own. Buffering
+ * is kept per log inside the service, so many appends within a synchronous
+ * block collapse into one durable write.
+ */
+
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+import { InstantiationType } from '#/_base/di/extensions';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+
+import { IStorageService } from './storageService';
+
+const textEncoder = new TextEncoder();
+const textDecoder = new TextDecoder();
+
+export class RecordCorruptedError extends Error {
+ constructor(
+ readonly scope: string,
+ readonly key: string,
+ readonly lineNumber: number,
+ cause: unknown,
+ ) {
+ super(`record log ${scope}/${key}: corrupted line ${lineNumber}: ${String(cause)}`);
+ this.name = 'RecordCorruptedError';
+ }
+}
+
+export interface IRecordStore {
+ readonly _serviceBrand: undefined;
+
+ /** Buffer a record for the next durable append. Resolves immediately. */
+ append(scope: string, key: string, record: R): void;
+
+ /**
+ * Replay the log in order. Flushes pending appends first. A torn final line
+ * (crash mid-flush) is dropped; any other corruption throws.
+ */
+ read(scope: string, key: string): AsyncIterable;
+
+ /** Atomically replace the whole log with `records` (used after migration). */
+ rewrite(scope: string, key: string, records: readonly R[]): Promise;
+
+ /** Durable-write every buffered record across all logs. */
+ flush(): Promise;
+
+ /** Flush and release resources. */
+ close(): Promise;
+}
+
+export const IRecordStore: ServiceIdentifier =
+ createDecorator('recordStore');
+
+interface LogState {
+ pending: unknown[];
+ flushPromise: Promise | undefined;
+ flushScheduled: boolean;
+}
+
+export class RecordStore implements IRecordStore {
+ declare readonly _serviceBrand: undefined;
+
+ private readonly logs = new Map();
+
+ constructor(@IStorageService private readonly storage: IStorageService) {}
+
+ append(scope: string, key: string, record: R): void {
+ const state = this.state(scope, key);
+ state.pending.push(record);
+ this.scheduleFlush(scope, key, state);
+ }
+
+ async *read(scope: string, key: string): AsyncIterable {
+ await this.flushLog(scope, key);
+ const bytes = await this.storage.read(scope, key);
+ if (bytes === undefined) return;
+
+ const lines = textDecoder.decode(bytes).split('\n');
+ for (let i = 0; i < lines.length; i++) {
+ const raw = lines[i]!;
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
+ if (line.length === 0) continue;
+ const isLast = i === lines.length - 1;
+ try {
+ yield JSON.parse(line) as R;
+ } catch (error) {
+ // A crash can leave a half-written last line; drop it. Corruption
+ // anywhere before the end is real and must surface.
+ if (isLast) return;
+ throw new RecordCorruptedError(scope, key, i + 1, error);
+ }
+ }
+ }
+
+ async rewrite(scope: string, key: string, records: readonly R[]): Promise {
+ // Persist anything already buffered, then atomically replace the log.
+ await this.flushLog(scope, key);
+ await this.storage.write(scope, key, encodeBatch(records), { atomic: true });
+ }
+
+ async flush(): Promise {
+ const inFlight = [...this.logs.keys()].map((id) => {
+ const { scope, key } = fromLogId(id);
+ return this.flushLog(scope, key);
+ });
+ await Promise.all(inFlight);
+ }
+
+ async close(): Promise {
+ await this.flush();
+ }
+
+ private state(scope: string, key: string): LogState {
+ const id = logId(scope, key);
+ let state = this.logs.get(id);
+ if (state === undefined) {
+ state = { pending: [], flushPromise: undefined, flushScheduled: false };
+ this.logs.set(id, state);
+ }
+ return state;
+ }
+
+ /**
+ * Defer the drain to the next microtask so records appended within the same
+ * synchronous block accumulate into a single durable `IStorageService.append`.
+ */
+ private scheduleFlush(scope: string, key: string, state: LogState): void {
+ if (state.flushScheduled || state.flushPromise !== undefined) return;
+ state.flushScheduled = true;
+ queueMicrotask(() => {
+ state.flushScheduled = false;
+ void this.flushLog(scope, key);
+ });
+ }
+
+ private flushLog(scope: string, key: string): Promise {
+ const state = this.state(scope, key);
+ if (state.flushPromise !== undefined) return state.flushPromise;
+
+ const promise = this.drain(scope, key, state).finally(() => {
+ if (state.flushPromise === promise) {
+ state.flushPromise = undefined;
+ }
+ // Records appended during the drain must be drained too.
+ if (state.pending.length > 0) {
+ void this.flushLog(scope, key);
+ }
+ });
+ state.flushPromise = promise;
+ return promise;
+ }
+
+ private async drain(scope: string, key: string, state: LogState): Promise {
+ while (state.pending.length > 0) {
+ const batch = state.pending.splice(0);
+ await this.storage.append(scope, key, encodeBatch(batch), { durable: true });
+ }
+ }
+}
+
+function logId(scope: string, key: string): string {
+ return `${scope}\n${key}`;
+}
+
+function fromLogId(id: string): { scope: string; key: string } {
+ const index = id.indexOf('\n');
+ return { scope: id.slice(0, index), key: id.slice(index + 1) };
+}
+
+function encodeBatch(records: readonly unknown[]): Uint8Array {
+ if (records.length === 0) return new Uint8Array(0);
+ const content = records.map((record) => JSON.stringify(record) + '\n').join('');
+ return textEncoder.encode(content);
+}
+
+registerScopedService(
+ LifecycleScope.Session,
+ IRecordStore,
+ RecordStore,
+ InstantiationType.Delayed,
+ 'storage',
+);
diff --git a/packages/agent-core-v2/src/storage/storageService.ts b/packages/agent-core-v2/src/storage/storageService.ts
new file mode 100644
index 000000000..5ff7cf230
--- /dev/null
+++ b/packages/agent-core-v2/src/storage/storageService.ts
@@ -0,0 +1,71 @@
+/**
+ * `storage` domain — the bottom-most persistence substrate.
+ *
+ * `IStorageService` is the single, backend-pluggable byte store that every
+ * other local-persistence abstraction is built on. It exposes the two
+ * irreducible durable primitives side by side:
+ *
+ * - `write` — atomic whole-value replacement (the `Config` access pattern).
+ * - `append` — ordered, durable byte extension (the `Record` access pattern).
+ *
+ * They are not interchangeable: building `append` on top of `write` is O(n)
+ * per append, and building `write` on top of `append` yields awkward "read
+ * the last value" semantics. Keeping both as first-class primitives lets each
+ * implementation implement them optimally (file: `open('a')` vs tmp+rename;
+ * db: `INSERT` vs `UPSERT`).
+ *
+ * The service is intentionally byte-oriented and scope/key-addressed: it knows
+ * nothing about JSON, records, configs, versions or framing. Those concerns
+ * live in the typed facades above it (`IRecordStore`, `IConfigStore`).
+ *
+ * `scope`/`key` are trusted internal path segments for the file implementation
+ * (e.g. scope `"agents/main"`, key `"wire.jsonl"`); they are not user input.
+ */
+
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+
+export interface StorageWriteOptions {
+ /**
+ * Replace atomically so readers never observe a half-written value. The file
+ * implementation always writes atomically; the option is reserved for
+ * implementations where atomicity is opt-in. Defaults to `true`.
+ */
+ readonly atomic?: boolean;
+}
+
+export interface StorageAppendOptions {
+ /**
+ * When `true` (default) the append is fsync'd before the call resolves so
+ * the bytes are durable across a crash. Set to `false` only for rebuildable
+ * data (caches/indexes) where throughput matters more than durability.
+ */
+ readonly durable?: boolean;
+}
+
+export interface IStorageService {
+ readonly _serviceBrand: undefined;
+
+ /** Read the whole value, or `undefined` when the key does not exist. */
+ read(scope: string, key: string): Promise;
+
+ /** Atomically replace the whole value. */
+ write(scope: string, key: string, data: Uint8Array, options?: StorageWriteOptions): Promise;
+
+ /** Durable byte extension (ordered). */
+ append(scope: string, key: string, data: Uint8Array, options?: StorageAppendOptions): Promise;
+
+ /** List the keys under `scope`, optionally filtered by `prefix`. */
+ list(scope: string, prefix?: string): Promise;
+
+ /** Delete a key. Missing keys are not an error. */
+ delete(scope: string, key: string): Promise;
+
+ /** Flush any buffered writes to the durable medium. */
+ flush(): Promise;
+
+ /** Release implementation resources (file handles, timers, connections). */
+ close(): Promise