@getforgeai/cli 0.1.0-beta.1

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 (221) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +80 -0
  3. package/dist/bin/forgeai.d.ts +3 -0
  4. package/dist/bin/forgeai.js +8 -0
  5. package/dist/cli.d.ts +4 -0
  6. package/dist/cli.js +180 -0
  7. package/dist/commands/auth-login.d.ts +4 -0
  8. package/dist/commands/auth-login.js +202 -0
  9. package/dist/commands/auth-logout.d.ts +2 -0
  10. package/dist/commands/auth-logout.js +41 -0
  11. package/dist/commands/auth-status.d.ts +4 -0
  12. package/dist/commands/auth-status.js +81 -0
  13. package/dist/commands/cli-list.d.ts +4 -0
  14. package/dist/commands/cli-list.js +40 -0
  15. package/dist/commands/cli-unlink.d.ts +4 -0
  16. package/dist/commands/cli-unlink.js +45 -0
  17. package/dist/commands/config-set.d.ts +5 -0
  18. package/dist/commands/config-set.js +63 -0
  19. package/dist/commands/connect.d.ts +9 -0
  20. package/dist/commands/connect.js +165 -0
  21. package/dist/commands/disconnect.d.ts +5 -0
  22. package/dist/commands/disconnect.js +126 -0
  23. package/dist/commands/journal-clear.d.ts +2 -0
  24. package/dist/commands/journal-clear.js +16 -0
  25. package/dist/commands/journal-list.d.ts +2 -0
  26. package/dist/commands/journal-list.js +20 -0
  27. package/dist/commands/journal-status.d.ts +2 -0
  28. package/dist/commands/journal-status.js +18 -0
  29. package/dist/commands/mcp-serve.d.ts +13 -0
  30. package/dist/commands/mcp-serve.js +74 -0
  31. package/dist/commands/org-list.d.ts +4 -0
  32. package/dist/commands/org-list.js +56 -0
  33. package/dist/commands/org-unlink.d.ts +4 -0
  34. package/dist/commands/org-unlink.js +63 -0
  35. package/dist/commands/reconnect.d.ts +4 -0
  36. package/dist/commands/reconnect.js +46 -0
  37. package/dist/commands/release-open.d.ts +2 -0
  38. package/dist/commands/release-open.js +43 -0
  39. package/dist/commands/session-respond.d.ts +2 -0
  40. package/dist/commands/session-respond.js +29 -0
  41. package/dist/commands/task-open.d.ts +2 -0
  42. package/dist/commands/task-open.js +42 -0
  43. package/dist/commands/vm.d.ts +21 -0
  44. package/dist/commands/vm.js +122 -0
  45. package/dist/commands/workflow-open.d.ts +2 -0
  46. package/dist/commands/workflow-open.js +43 -0
  47. package/dist/config/config-manager.d.ts +31 -0
  48. package/dist/config/config-manager.js +70 -0
  49. package/dist/config/token-manager.d.ts +11 -0
  50. package/dist/config/token-manager.js +87 -0
  51. package/dist/index.d.ts +11 -0
  52. package/dist/index.js +22 -0
  53. package/dist/lib/auth-client.d.ts +1670 -0
  54. package/dist/lib/auth-client.js +10 -0
  55. package/dist/lib/branch-manager.d.ts +79 -0
  56. package/dist/lib/branch-manager.js +620 -0
  57. package/dist/lib/clone-and-checkout.d.ts +6 -0
  58. package/dist/lib/clone-and-checkout.js +41 -0
  59. package/dist/lib/connection-manager.d.ts +58 -0
  60. package/dist/lib/connection-manager.js +436 -0
  61. package/dist/lib/daemon-entry.d.ts +2 -0
  62. package/dist/lib/daemon-entry.js +210 -0
  63. package/dist/lib/daemon.d.ts +38 -0
  64. package/dist/lib/daemon.js +148 -0
  65. package/dist/lib/event-journal.d.ts +24 -0
  66. package/dist/lib/event-journal.js +76 -0
  67. package/dist/lib/ipc-client.d.ts +10 -0
  68. package/dist/lib/ipc-client.js +70 -0
  69. package/dist/lib/ipc-server.d.ts +36 -0
  70. package/dist/lib/ipc-server.js +70 -0
  71. package/dist/lib/node-version-guard.d.ts +8 -0
  72. package/dist/lib/node-version-guard.js +60 -0
  73. package/dist/lib/open-directory.d.ts +7 -0
  74. package/dist/lib/open-directory.js +41 -0
  75. package/dist/lib/repo-manager.d.ts +29 -0
  76. package/dist/lib/repo-manager.js +103 -0
  77. package/dist/lib/resilient-emitter.d.ts +53 -0
  78. package/dist/lib/resilient-emitter.js +160 -0
  79. package/dist/lib/rest-client.d.ts +164 -0
  80. package/dist/lib/rest-client.js +139 -0
  81. package/dist/lib/skill-manager.d.ts +23 -0
  82. package/dist/lib/skill-manager.js +167 -0
  83. package/dist/lib/socket-client.d.ts +68 -0
  84. package/dist/lib/socket-client.js +160 -0
  85. package/dist/mcp/mcp-probe.d.ts +11 -0
  86. package/dist/mcp/mcp-probe.js +106 -0
  87. package/dist/mcp/mcp-response.d.ts +29 -0
  88. package/dist/mcp/mcp-response.js +67 -0
  89. package/dist/mcp/mcp-server.d.ts +19 -0
  90. package/dist/mcp/mcp-server.js +76 -0
  91. package/dist/mcp/resources/project-conventions.d.ts +4 -0
  92. package/dist/mcp/resources/project-conventions.js +50 -0
  93. package/dist/mcp/resources/task-current.d.ts +4 -0
  94. package/dist/mcp/resources/task-current.js +51 -0
  95. package/dist/mcp/tools/forge-add-group-dependency.d.ts +4 -0
  96. package/dist/mcp/tools/forge-add-group-dependency.js +20 -0
  97. package/dist/mcp/tools/forge-add-task-dependency.d.ts +4 -0
  98. package/dist/mcp/tools/forge-add-task-dependency.js +20 -0
  99. package/dist/mcp/tools/forge-ask-human.d.ts +4 -0
  100. package/dist/mcp/tools/forge-ask-human.js +80 -0
  101. package/dist/mcp/tools/forge-column-done.d.ts +4 -0
  102. package/dist/mcp/tools/forge-column-done.js +46 -0
  103. package/dist/mcp/tools/forge-complete-step.d.ts +4 -0
  104. package/dist/mcp/tools/forge-complete-step.js +139 -0
  105. package/dist/mcp/tools/forge-create-task-group.d.ts +4 -0
  106. package/dist/mcp/tools/forge-create-task-group.js +22 -0
  107. package/dist/mcp/tools/forge-create-task.d.ts +4 -0
  108. package/dist/mcp/tools/forge-create-task.js +28 -0
  109. package/dist/mcp/tools/forge-delete-task-group.d.ts +4 -0
  110. package/dist/mcp/tools/forge-delete-task-group.js +17 -0
  111. package/dist/mcp/tools/forge-delete-task.d.ts +4 -0
  112. package/dist/mcp/tools/forge-delete-task.js +17 -0
  113. package/dist/mcp/tools/forge-get-task.d.ts +4 -0
  114. package/dist/mcp/tools/forge-get-task.js +17 -0
  115. package/dist/mcp/tools/forge-merge-back.d.ts +4 -0
  116. package/dist/mcp/tools/forge-merge-back.js +179 -0
  117. package/dist/mcp/tools/forge-release-merge.d.ts +4 -0
  118. package/dist/mcp/tools/forge-release-merge.js +159 -0
  119. package/dist/mcp/tools/forge-release-notes.d.ts +4 -0
  120. package/dist/mcp/tools/forge-release-notes.js +51 -0
  121. package/dist/mcp/tools/forge-release-tag.d.ts +4 -0
  122. package/dist/mcp/tools/forge-release-tag.js +125 -0
  123. package/dist/mcp/tools/forge-remove-group-dependency.d.ts +4 -0
  124. package/dist/mcp/tools/forge-remove-group-dependency.js +19 -0
  125. package/dist/mcp/tools/forge-remove-task-dependency.d.ts +4 -0
  126. package/dist/mcp/tools/forge-remove-task-dependency.js +19 -0
  127. package/dist/mcp/tools/forge-requeue.d.ts +4 -0
  128. package/dist/mcp/tools/forge-requeue.js +39 -0
  129. package/dist/mcp/tools/forge-submit-pr.d.ts +4 -0
  130. package/dist/mcp/tools/forge-submit-pr.js +68 -0
  131. package/dist/mcp/tools/forge-update-status.d.ts +4 -0
  132. package/dist/mcp/tools/forge-update-status.js +77 -0
  133. package/dist/mcp/tools/forge-update-task-group.d.ts +4 -0
  134. package/dist/mcp/tools/forge-update-task-group.js +21 -0
  135. package/dist/mcp/tools/forge-update-task.d.ts +4 -0
  136. package/dist/mcp/tools/forge-update-task.js +25 -0
  137. package/dist/mcp/tools/forge-validate-merge.d.ts +4 -0
  138. package/dist/mcp/tools/forge-validate-merge.js +194 -0
  139. package/dist/mcp/types.d.ts +39 -0
  140. package/dist/mcp/types.js +8 -0
  141. package/dist/methodology/artifact-manager.d.ts +14 -0
  142. package/dist/methodology/artifact-manager.js +55 -0
  143. package/dist/orchestrator/agent-registry.d.ts +53 -0
  144. package/dist/orchestrator/agent-registry.js +96 -0
  145. package/dist/orchestrator/agent-spawner.d.ts +112 -0
  146. package/dist/orchestrator/agent-spawner.js +1518 -0
  147. package/dist/orchestrator/connectors/claude-code-connector.d.ts +3 -0
  148. package/dist/orchestrator/connectors/claude-code-connector.js +60 -0
  149. package/dist/orchestrator/connectors/connector.d.ts +95 -0
  150. package/dist/orchestrator/connectors/connector.js +25 -0
  151. package/dist/orchestrator/connectors/docker-connector.d.ts +14 -0
  152. package/dist/orchestrator/connectors/docker-connector.js +337 -0
  153. package/dist/orchestrator/connectors/native-vm-connector.d.ts +11 -0
  154. package/dist/orchestrator/connectors/native-vm-connector.js +162 -0
  155. package/dist/orchestrator/connectors/opencode-connector.d.ts +3 -0
  156. package/dist/orchestrator/connectors/opencode-connector.js +39 -0
  157. package/dist/orchestrator/connectors/vm-connector.d.ts +3 -0
  158. package/dist/orchestrator/connectors/vm-connector.js +139 -0
  159. package/dist/orchestrator/dispatch-queue.d.ts +43 -0
  160. package/dist/orchestrator/dispatch-queue.js +93 -0
  161. package/dist/orchestrator/resume-handler.d.ts +34 -0
  162. package/dist/orchestrator/resume-handler.js +159 -0
  163. package/dist/orchestrator/stream-batcher.d.ts +16 -0
  164. package/dist/orchestrator/stream-batcher.js +50 -0
  165. package/dist/orchestrator/stream-json-extractor.d.ts +97 -0
  166. package/dist/orchestrator/stream-json-extractor.js +579 -0
  167. package/dist/vm/claude-token-manager.d.ts +36 -0
  168. package/dist/vm/claude-token-manager.js +223 -0
  169. package/dist/vm/docker-executor.d.ts +131 -0
  170. package/dist/vm/docker-executor.js +360 -0
  171. package/dist/vm/docker-process-proxy.d.ts +31 -0
  172. package/dist/vm/docker-process-proxy.js +105 -0
  173. package/dist/vm/forge-vm-agent.d.ts +11 -0
  174. package/dist/vm/forge-vm-agent.js +159 -0
  175. package/dist/vm/git-credentials.d.ts +43 -0
  176. package/dist/vm/git-credentials.js +53 -0
  177. package/dist/vm/image-manager.d.ts +7 -0
  178. package/dist/vm/image-manager.js +61 -0
  179. package/dist/vm/log.d.ts +9 -0
  180. package/dist/vm/log.js +18 -0
  181. package/dist/vm/mcp-bridge.d.ts +63 -0
  182. package/dist/vm/mcp-bridge.js +243 -0
  183. package/dist/vm/mcp-stub.d.ts +15 -0
  184. package/dist/vm/mcp-stub.js +67 -0
  185. package/dist/vm/native-addon.d.ts +69 -0
  186. package/dist/vm/native-addon.js +68 -0
  187. package/dist/vm/provider-resolver.d.ts +7 -0
  188. package/dist/vm/provider-resolver.js +27 -0
  189. package/dist/vm/providers/lima.d.ts +17 -0
  190. package/dist/vm/providers/lima.js +191 -0
  191. package/dist/vm/providers/qemu.d.ts +28 -0
  192. package/dist/vm/providers/qemu.js +260 -0
  193. package/dist/vm/providers/vfkit.d.ts +40 -0
  194. package/dist/vm/providers/vfkit.js +371 -0
  195. package/dist/vm/providers/wsl2.d.ts +27 -0
  196. package/dist/vm/providers/wsl2.js +226 -0
  197. package/dist/vm/session-manager.d.ts +130 -0
  198. package/dist/vm/session-manager.js +494 -0
  199. package/dist/vm/session-snapshot.d.ts +31 -0
  200. package/dist/vm/session-snapshot.js +180 -0
  201. package/dist/vm/types.d.ts +73 -0
  202. package/dist/vm/types.js +2 -0
  203. package/dist/vm/vm-helper-client.d.ts +81 -0
  204. package/dist/vm/vm-helper-client.js +335 -0
  205. package/dist/vm/vm-helper.d.ts +11 -0
  206. package/dist/vm/vm-helper.js +156 -0
  207. package/dist/vm/vm-ipc-protocol.d.ts +84 -0
  208. package/dist/vm/vm-ipc-protocol.js +9 -0
  209. package/dist/vm/vm-manager-facade.d.ts +23 -0
  210. package/dist/vm/vm-manager-facade.js +43 -0
  211. package/dist/vm/vm-manager.d.ts +74 -0
  212. package/dist/vm/vm-manager.js +515 -0
  213. package/dist/vm/vm-process-proxy.d.ts +34 -0
  214. package/dist/vm/vm-process-proxy.js +64 -0
  215. package/dist/vm/vsock-bridge.d.ts +44 -0
  216. package/dist/vm/vsock-bridge.js +140 -0
  217. package/dist/vm/vsock-protocol.d.ts +98 -0
  218. package/dist/vm/vsock-protocol.js +57 -0
  219. package/package.json +65 -0
  220. package/rootfs/Dockerfile +50 -0
  221. package/rootfs/forge-mcp-relay.mjs +158 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * High-level session orchestration for Docker containers.
3
+ *
4
+ * Unlike the old VM-based approach, the SessionManager now runs
5
+ * 1 Docker container per session. Each container is a lightweight
6
+ * isolated environment where we can execute git, compile, and spawn agents.
7
+ *
8
+ * Lifecycle:
9
+ * 1. ensureVmRunning() — lazy check Docker availability + ensure image
10
+ * 2. createSession() — allocate session dir + create + start container
11
+ * 3. cloneRepo() — git clone inside container via exec()
12
+ * 4. prepareWorkspace() — write context/skills/MCP config to host volume
13
+ * 5. spawnAgent() — launch agent in container, returns DockerProcessProxy
14
+ * 6. destroySession() — kill container + cleanup host files
15
+ * 7. shutdown() — cleanup all containers
16
+ */
17
+ import type { ChildProcess } from "node:child_process";
18
+ import { DockerProcessProxy } from "./docker-process-proxy.js";
19
+ import { type GitCredentials } from "./git-credentials.js";
20
+ export type SessionState = {
21
+ id: string;
22
+ taskId: string;
23
+ sessionIndex: number;
24
+ sessionDir: string;
25
+ containerId: string;
26
+ status: "CREATED" | "SPAWNING" | "RUNNING" | "STOPPED";
27
+ agentPid?: number;
28
+ agentProcess?: ChildProcess;
29
+ /**
30
+ * Per-session shared secret for the MCP bridge auth handshake.
31
+ * Generated at session creation, stable across agent resume (--continue),
32
+ * never logged. Passed to the in-container relay via FORGEAI_MCP_TOKEN.
33
+ */
34
+ mcpSecret: string;
35
+ /**
36
+ * Git credential for this session's repo, resolved from the host credential
37
+ * helper at clone time. Kept in memory only (never written to disk or
38
+ * .git/config) so CLI-driven pushes (branch tracking, autoSaveCode) can
39
+ * re-supply it out-of-band via an env-var credential helper. Undefined for
40
+ * ssh/public repos.
41
+ */
42
+ gitCredentials?: GitCredentials;
43
+ };
44
+ export declare function getSessionManager(): SessionManager;
45
+ export declare function initSessionManager(): SessionManager;
46
+ export declare class SessionManager {
47
+ private sessions;
48
+ private sessionCounter;
49
+ private dockerReady;
50
+ private claudeOAuthToken;
51
+ private tokenLoadedAt;
52
+ /**
53
+ * Ensure Docker is available and image is ready.
54
+ * Idempotent: if already checked, returns immediately.
55
+ */
56
+ ensureVmRunning(): Promise<void>;
57
+ /**
58
+ * Create a session for a task agent.
59
+ * Allocates a session index, creates host directories, spins up container.
60
+ */
61
+ createSession(taskId: string): Promise<string>;
62
+ /**
63
+ * Execute a command inside a session's container.
64
+ * Used for git clone, initial setup, etc.
65
+ */
66
+ exec(taskId: string, cmd: string, args: string[], cwd?: string): Promise<{
67
+ stdout: string;
68
+ stderr: string;
69
+ exitCode: number;
70
+ }>;
71
+ /**
72
+ * Clone a git repo into the session workspace inside the container.
73
+ * Creates the branch if it doesn't exist yet (forking from baseBranch).
74
+ */
75
+ cloneRepo(sessionId: string, repoUrl: string | undefined, branch?: string, baseBranch?: string): Promise<void>;
76
+ /**
77
+ * Write session files (context, MCP config, skills) to the session directory
78
+ * on the host. These are accessible in the container via volume mount at /session/.
79
+ */
80
+ prepareWorkspace(sessionId: string, files: {
81
+ contextJson: string;
82
+ mcpConfigJson: string;
83
+ skillFiles: Array<{
84
+ relativePath: string;
85
+ content: string;
86
+ }>;
87
+ }): void;
88
+ /**
89
+ * Spawn an agent inside the container.
90
+ * Returns a DockerProcessProxy that satisfies the AgentProcess interface.
91
+ */
92
+ spawnAgent(taskId: string, cmd: string, args: string[], env?: Record<string, string>): Promise<DockerProcessProxy>;
93
+ /**
94
+ * Kill an agent process.
95
+ */
96
+ killAgent(taskIdOrSessionId: string, signal?: string): Promise<void>;
97
+ /**
98
+ * Destroy a session (stop container + cleanup host files).
99
+ */
100
+ destroySession(taskId: string): Promise<void>;
101
+ /**
102
+ * Graceful shutdown: remove all managed containers and cleanup.
103
+ */
104
+ shutdown(): Promise<void>;
105
+ getSessionByTaskId(taskId: string): SessionState | undefined;
106
+ getAllSessions(): SessionState[];
107
+ isVmRunning(): boolean;
108
+ /**
109
+ * Reload token from Keychain/file. Called after auth errors to pick up
110
+ * a refreshed token without restarting the CLI.
111
+ * Returns true if a new token was loaded.
112
+ */
113
+ refreshToken(): Promise<boolean>;
114
+ /**
115
+ * Look up a session by taskId (primary key) or by sessionId (fallback).
116
+ */
117
+ private getSession;
118
+ /**
119
+ * Resolve git credentials for a repo URL from the host's git credential
120
+ * helper, WITHOUT inlining them into the URL.
121
+ *
122
+ * Returns the clean (userinfo-free) URL to clone from, plus the username /
123
+ * password to supply out-of-band. SSH URLs and repos with no stored
124
+ * credential resolve to `{ cleanUrl }` with no credentials (the clone then
125
+ * proceeds unauthenticated — fine for public repos; a private repo will fail
126
+ * with git's own auth error rather than leaking anything).
127
+ */
128
+ private resolveGitCredentials;
129
+ }
130
+ //# sourceMappingURL=session-manager.d.ts.map
@@ -0,0 +1,494 @@
1
+ /**
2
+ * High-level session orchestration for Docker containers.
3
+ *
4
+ * Unlike the old VM-based approach, the SessionManager now runs
5
+ * 1 Docker container per session. Each container is a lightweight
6
+ * isolated environment where we can execute git, compile, and spawn agents.
7
+ *
8
+ * Lifecycle:
9
+ * 1. ensureVmRunning() — lazy check Docker availability + ensure image
10
+ * 2. createSession() — allocate session dir + create + start container
11
+ * 3. cloneRepo() — git clone inside container via exec()
12
+ * 4. prepareWorkspace() — write context/skills/MCP config to host volume
13
+ * 5. spawnAgent() — launch agent in container, returns DockerProcessProxy
14
+ * 6. destroySession() — kill container + cleanup host files
15
+ * 7. shutdown() — cleanup all containers
16
+ */
17
+ import { copyFileSync, existsSync, mkdirSync, writeFileSync, rmSync, } from "node:fs";
18
+ import { randomBytes } from "node:crypto";
19
+ import { homedir } from "node:os";
20
+ import { join, resolve } from "node:path";
21
+ import { execInContainer, spawnInContainer, createContainer, startContainer, removeContainer, isDockerAvailable, } from "./docker-executor.js";
22
+ import { DockerProcessProxy } from "./docker-process-proxy.js";
23
+ import { gitCredentialConfigArgs, gitCredentialSecretEnv, } from "./git-credentials.js";
24
+ import { ensureImage, FORGEAI_IMAGE } from "./image-manager.js";
25
+ import { vmLog } from "./log.js";
26
+ import { ensureClaudeToken } from "./claude-token-manager.js";
27
+ // ---------------------------------------------------------------------------
28
+ // Paths
29
+ // ---------------------------------------------------------------------------
30
+ const SESSIONS_DIR = join(homedir(), ".forgeai", "docker", "sessions");
31
+ /**
32
+ * Path to the canonical forge-mcp-relay source shipped with the CLI.
33
+ * From dist/vm/ (or src/vm/), go up to the cli root, then rootfs/.
34
+ * The relay is staged into each session dir so the version running in the
35
+ * container always matches this CLI build — no Docker image rebuild needed.
36
+ */
37
+ function getRelaySourcePath() {
38
+ return join(import.meta.dirname, "..", "..", "rootfs", "forge-mcp-relay.mjs");
39
+ }
40
+ // ---------------------------------------------------------------------------
41
+ // Singleton
42
+ // ---------------------------------------------------------------------------
43
+ let instance = null;
44
+ export function getSessionManager() {
45
+ if (!instance)
46
+ throw new Error("SessionManager not initialized");
47
+ return instance;
48
+ }
49
+ export function initSessionManager() {
50
+ instance = new SessionManager();
51
+ return instance;
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // SessionManager
55
+ // ---------------------------------------------------------------------------
56
+ export class SessionManager {
57
+ sessions = new Map();
58
+ sessionCounter = 0;
59
+ dockerReady = false;
60
+ claudeOAuthToken = "";
61
+ tokenLoadedAt = 0;
62
+ // -------------------------------------------------------------------------
63
+ // VM lifecycle (now Docker)
64
+ // -------------------------------------------------------------------------
65
+ /**
66
+ * Ensure Docker is available and image is ready.
67
+ * Idempotent: if already checked, returns immediately.
68
+ */
69
+ async ensureVmRunning() {
70
+ // Re-read token from Keychain every hour (catches external refresh via setup-claude)
71
+ if (this.dockerReady) {
72
+ const TOKEN_REFRESH_MS = 60 * 60 * 1000; // 1 hour
73
+ if (Date.now() - this.tokenLoadedAt > TOKEN_REFRESH_MS) {
74
+ await this.refreshToken();
75
+ }
76
+ return;
77
+ }
78
+ // Check Docker availability
79
+ const dockerAvailable = await isDockerAvailable();
80
+ if (!dockerAvailable) {
81
+ throw new Error("Docker is not available — install Docker Desktop or docker-ce");
82
+ }
83
+ // Ensure ForgeAI image exists (pull or build if needed)
84
+ await ensureImage();
85
+ // Create host directories
86
+ mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
87
+ // Obtain Claude OAuth token for container authentication
88
+ this.claudeOAuthToken = await ensureClaudeToken();
89
+ this.tokenLoadedAt = Date.now();
90
+ this.dockerReady = true;
91
+ vmLog.cyan("session-manager", "Docker ready");
92
+ }
93
+ // -------------------------------------------------------------------------
94
+ // Session management
95
+ // -------------------------------------------------------------------------
96
+ /**
97
+ * Create a session for a task agent.
98
+ * Allocates a session index, creates host directories, spins up container.
99
+ */
100
+ async createSession(taskId) {
101
+ if (!this.dockerReady) {
102
+ throw new Error("Docker not ready — call ensureVmRunning() first");
103
+ }
104
+ const sessionIndex = this.sessionCounter++;
105
+ const sanitizedId = taskId.slice(0, 12).replace(/[^a-zA-Z0-9-]/g, "");
106
+ const sessionId = `s-${sanitizedId}-${sessionIndex}`;
107
+ const sessionDir = join(SESSIONS_DIR, sessionId);
108
+ // Clean up stale session directory from previous failed runs
109
+ if (existsSync(sessionDir)) {
110
+ rmSync(sessionDir, { recursive: true, force: true });
111
+ }
112
+ mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
113
+ // Create Docker container
114
+ const containerId = await createContainer({
115
+ name: `forgeai-${sessionId}`,
116
+ image: FORGEAI_IMAGE,
117
+ volumes: [
118
+ {
119
+ hostPath: sessionDir,
120
+ containerPath: "/session",
121
+ readOnly: false,
122
+ },
123
+ ],
124
+ sessionId,
125
+ taskId,
126
+ });
127
+ // Start container
128
+ await startContainer(containerId);
129
+ const session = {
130
+ id: sessionId,
131
+ taskId,
132
+ sessionIndex,
133
+ sessionDir,
134
+ containerId,
135
+ status: "CREATED",
136
+ // Crypto-random per-session secret for the MCP bridge auth handshake
137
+ mcpSecret: randomBytes(32).toString("hex"),
138
+ };
139
+ this.sessions.set(taskId, session);
140
+ vmLog.info("session-manager", `Session created: ${sessionId} (container: ${containerId.slice(0, 12)})`, sessionId);
141
+ return sessionId;
142
+ }
143
+ // -------------------------------------------------------------------------
144
+ // Command execution inside the container
145
+ // -------------------------------------------------------------------------
146
+ /**
147
+ * Execute a command inside a session's container.
148
+ * Used for git clone, initial setup, etc.
149
+ */
150
+ async exec(taskId, cmd, args, cwd) {
151
+ const session = this.getSession(taskId);
152
+ if (!session) {
153
+ throw new Error(`Session for task ${taskId} not found`);
154
+ }
155
+ const result = await execInContainer(session.containerId, [cmd, ...args], {
156
+ cwd,
157
+ timeout: 600_000, // 10 minutes
158
+ });
159
+ return result;
160
+ }
161
+ // -------------------------------------------------------------------------
162
+ // Git operations
163
+ // -------------------------------------------------------------------------
164
+ /**
165
+ * Clone a git repo into the session workspace inside the container.
166
+ * Creates the branch if it doesn't exist yet (forking from baseBranch).
167
+ */
168
+ async cloneRepo(sessionId, repoUrl, branch, baseBranch) {
169
+ if (!repoUrl)
170
+ return;
171
+ const session = this.getSession(sessionId);
172
+ if (!session)
173
+ throw new Error(`Session ${sessionId} not found`);
174
+ const { cleanUrl, credentials } = await this.resolveGitCredentials(repoUrl);
175
+ // Persist in memory so CLI-driven pushes later in the session (branch
176
+ // tracking below, autoSaveCode) can re-supply the credential out-of-band.
177
+ session.gitCredentials = credentials;
178
+ const workspacePath = "/session/workspace";
179
+ // Credential injection for network git ops: clean URL + inline helper that
180
+ // reads the value from FORGE_GIT_* env vars (passed via secretEnv, so the
181
+ // value is never in argv and never persists in .git/config).
182
+ const credArgs = credentials ? gitCredentialConfigArgs() : [];
183
+ const credEnv = credentials
184
+ ? { secretEnv: gitCredentialSecretEnv(credentials) }
185
+ : undefined;
186
+ vmLog.cyan("session-manager", `Cloning ${repoUrl} into ${sessionId}...`, sessionId);
187
+ const cloneResult = await execInContainer(session.containerId, ["git", ...credArgs, "clone", cleanUrl, workspacePath], credEnv);
188
+ // Configure git identity for commits (required by autoSaveCode)
189
+ await execInContainer(session.containerId, [
190
+ "git",
191
+ "-C",
192
+ workspacePath,
193
+ "config",
194
+ "user.email",
195
+ "agent@forgeai.dev",
196
+ ]);
197
+ await execInContainer(session.containerId, [
198
+ "git",
199
+ "-C",
200
+ workspacePath,
201
+ "config",
202
+ "user.name",
203
+ "ForgeAI Agent",
204
+ ]);
205
+ if (cloneResult.exitCode !== 0) {
206
+ throw new Error(`Git clone failed (exit ${cloneResult.exitCode}): ${cloneResult.stderr}`);
207
+ }
208
+ if (branch) {
209
+ // Try checking out an existing remote branch first
210
+ const checkoutResult = await execInContainer(session.containerId, [
211
+ "git",
212
+ "-C",
213
+ workspacePath,
214
+ "checkout",
215
+ branch,
216
+ ]);
217
+ if (checkoutResult.exitCode !== 0) {
218
+ // Branch doesn't exist remotely — create it from baseBranch or HEAD
219
+ const base = baseBranch ? `origin/${baseBranch}` : "HEAD";
220
+ const createResult = await execInContainer(session.containerId, [
221
+ "git",
222
+ "-C",
223
+ workspacePath,
224
+ "checkout",
225
+ "-b",
226
+ branch,
227
+ base,
228
+ ]);
229
+ if (createResult.exitCode !== 0) {
230
+ throw new Error(`Failed to create branch ${branch}: ${createResult.stderr}`);
231
+ }
232
+ // Push the new branch to set up tracking (network op — needs the
233
+ // credential helper; clean remote URL has no embedded userinfo).
234
+ await execInContainer(session.containerId, [
235
+ "git",
236
+ "-C",
237
+ workspacePath,
238
+ ...credArgs,
239
+ "push",
240
+ "-u",
241
+ "origin",
242
+ branch,
243
+ ], credEnv);
244
+ }
245
+ }
246
+ }
247
+ // -------------------------------------------------------------------------
248
+ // Workspace preparation
249
+ // -------------------------------------------------------------------------
250
+ /**
251
+ * Write session files (context, MCP config, skills) to the session directory
252
+ * on the host. These are accessible in the container via volume mount at /session/.
253
+ */
254
+ prepareWorkspace(sessionId, files) {
255
+ const session = this.getSession(sessionId);
256
+ if (!session)
257
+ throw new Error(`Session ${sessionId} not found`);
258
+ // Write to host session dir (mounted at /session in container)
259
+ writeFileSync(join(session.sessionDir, "context.json"), files.contextJson, "utf-8");
260
+ writeFileSync(join(session.sessionDir, "mcp_config.json"), files.mcpConfigJson, "utf-8");
261
+ // Stage the MCP relay so the container runs the version matching this
262
+ // CLI build (with the auth handshake) even on a stale Docker image.
263
+ const relaySource = getRelaySourcePath();
264
+ if (!existsSync(relaySource)) {
265
+ throw new Error(`forge-mcp-relay.mjs not found at ${relaySource}`);
266
+ }
267
+ copyFileSync(relaySource, join(session.sessionDir, "forge-mcp-relay.mjs"));
268
+ for (const file of files.skillFiles) {
269
+ // Guard against path traversal: resolve the full path and verify
270
+ // it stays within the session directory.
271
+ const filePath = resolve(session.sessionDir, "skills", file.relativePath);
272
+ if (!filePath.startsWith(resolve(session.sessionDir))) {
273
+ throw new Error(`Path traversal detected: ${file.relativePath}`);
274
+ }
275
+ const fileDir = join(filePath, "..");
276
+ mkdirSync(fileDir, { recursive: true });
277
+ writeFileSync(filePath, file.content, "utf-8");
278
+ }
279
+ }
280
+ // -------------------------------------------------------------------------
281
+ // Agent spawning
282
+ // -------------------------------------------------------------------------
283
+ /**
284
+ * Spawn an agent inside the container.
285
+ * Returns a DockerProcessProxy that satisfies the AgentProcess interface.
286
+ */
287
+ async spawnAgent(taskId, cmd, args, env = {}) {
288
+ const session = this.getSession(taskId);
289
+ if (!session)
290
+ throw new Error(`Session for task ${taskId} not found`);
291
+ session.status = "SPAWNING";
292
+ // Log the full command for debugging
293
+ const promptArgIdx = args.indexOf("-p");
294
+ const promptPreview = promptArgIdx >= 0 && args[promptArgIdx + 1]
295
+ ? `"${args[promptArgIdx + 1].slice(0, 100)}${args[promptArgIdx + 1].length > 100 ? "..." : ""}"`
296
+ : "(no -p)";
297
+ const flags = args.filter((a) => a.startsWith("--")).join(" ");
298
+ vmLog.magenta("session-manager", `${cmd} ${flags} -p ${promptPreview}`, session.id);
299
+ // Spawn the process in the container
300
+ const dockerExecProcess = spawnInContainer(session.containerId, [cmd, ...args], {
301
+ cwd: "/session/workspace",
302
+ env,
303
+ // The long-lived Claude token goes through secretEnv (name-only -e) so
304
+ // it never lands in the host process table / `ps` output.
305
+ secretEnv: { CLAUDE_CODE_OAUTH_TOKEN: this.claudeOAuthToken },
306
+ user: "agent",
307
+ });
308
+ // Create a kill function that removes the container when the process exits
309
+ const killFn = async (signal) => {
310
+ try {
311
+ dockerExecProcess.kill((signal ?? "SIGTERM"));
312
+ }
313
+ catch {
314
+ /* best effort */
315
+ }
316
+ };
317
+ // Create proxy wrapping the docker exec process
318
+ const proxy = new DockerProcessProxy(session.id, dockerExecProcess, killFn);
319
+ session.agentPid = proxy.pid;
320
+ session.agentProcess = dockerExecProcess;
321
+ session.status = "RUNNING";
322
+ vmLog.info("session-manager", `Agent spawned in container ${session.id} (PID: ${proxy.pid})`, session.id);
323
+ return proxy;
324
+ }
325
+ // -------------------------------------------------------------------------
326
+ // Agent lifecycle
327
+ // -------------------------------------------------------------------------
328
+ /**
329
+ * Kill an agent process.
330
+ */
331
+ async killAgent(taskIdOrSessionId, signal = "SIGTERM") {
332
+ const session = this.getSession(taskIdOrSessionId);
333
+ if (!session || !session.agentProcess)
334
+ return;
335
+ session.agentProcess.kill(signal);
336
+ }
337
+ /**
338
+ * Destroy a session (stop container + cleanup host files).
339
+ */
340
+ async destroySession(taskId) {
341
+ const session = this.sessions.get(taskId);
342
+ if (!session)
343
+ return;
344
+ // Kill agent if still running
345
+ if (session.status === "RUNNING" && session.agentProcess) {
346
+ await this.killAgent(taskId);
347
+ }
348
+ // Remove Docker container
349
+ try {
350
+ await removeContainer(session.containerId, true);
351
+ }
352
+ catch {
353
+ /* container may already be gone */
354
+ }
355
+ // Clean up host session directory
356
+ if (existsSync(session.sessionDir)) {
357
+ rmSync(session.sessionDir, { recursive: true, force: true });
358
+ }
359
+ this.sessions.delete(taskId);
360
+ vmLog.info("session-manager", `Session destroyed: ${session.id}`, session.id);
361
+ }
362
+ // -------------------------------------------------------------------------
363
+ // Shutdown
364
+ // -------------------------------------------------------------------------
365
+ /**
366
+ * Graceful shutdown: remove all managed containers and cleanup.
367
+ */
368
+ async shutdown() {
369
+ vmLog.cyan("session-manager", "Shutting down...");
370
+ // Destroy all sessions
371
+ const taskIds = Array.from(this.sessions.keys());
372
+ for (const taskId of taskIds) {
373
+ try {
374
+ await this.destroySession(taskId);
375
+ }
376
+ catch {
377
+ /* best effort */
378
+ }
379
+ }
380
+ this.sessions.clear();
381
+ this.dockerReady = false;
382
+ vmLog.info("session-manager", "Shutdown complete");
383
+ }
384
+ // -------------------------------------------------------------------------
385
+ // Queries
386
+ // -------------------------------------------------------------------------
387
+ getSessionByTaskId(taskId) {
388
+ return this.sessions.get(taskId);
389
+ }
390
+ getAllSessions() {
391
+ return Array.from(this.sessions.values());
392
+ }
393
+ isVmRunning() {
394
+ return this.dockerReady;
395
+ }
396
+ /**
397
+ * Reload token from Keychain/file. Called after auth errors to pick up
398
+ * a refreshed token without restarting the CLI.
399
+ * Returns true if a new token was loaded.
400
+ */
401
+ async refreshToken() {
402
+ const { loadClaudeToken } = await import("./claude-token-manager.js");
403
+ const fresh = await loadClaudeToken();
404
+ const preview = (t) => `${t.slice(0, 15)}...${t.slice(-4)}`;
405
+ if (!fresh) {
406
+ vmLog.warn("session-manager", "No token found in Keychain");
407
+ return false;
408
+ }
409
+ if (fresh === this.claudeOAuthToken) {
410
+ vmLog.warn("session-manager", `Token unchanged in Keychain (${preview(fresh)}) — same expired token. Run: forge auth setup-claude`);
411
+ return false;
412
+ }
413
+ vmLog.cyan("session-manager", `Token refreshed: ${preview(this.claudeOAuthToken)} → ${preview(fresh)}`);
414
+ this.claudeOAuthToken = fresh;
415
+ this.tokenLoadedAt = Date.now();
416
+ return true;
417
+ }
418
+ // -------------------------------------------------------------------------
419
+ // Private helpers
420
+ // -------------------------------------------------------------------------
421
+ /**
422
+ * Look up a session by taskId (primary key) or by sessionId (fallback).
423
+ */
424
+ getSession(idOrTaskId) {
425
+ // Try taskId first (Map key)
426
+ const byTask = this.sessions.get(idOrTaskId);
427
+ if (byTask)
428
+ return byTask;
429
+ // Fallback: search by sessionId
430
+ for (const session of this.sessions.values()) {
431
+ if (session.id === idOrTaskId)
432
+ return session;
433
+ }
434
+ return undefined;
435
+ }
436
+ /**
437
+ * Resolve git credentials for a repo URL from the host's git credential
438
+ * helper, WITHOUT inlining them into the URL.
439
+ *
440
+ * Returns the clean (userinfo-free) URL to clone from, plus the username /
441
+ * password to supply out-of-band. SSH URLs and repos with no stored
442
+ * credential resolve to `{ cleanUrl }` with no credentials (the clone then
443
+ * proceeds unauthenticated — fine for public repos; a private repo will fail
444
+ * with git's own auth error rather than leaking anything).
445
+ */
446
+ async resolveGitCredentials(repoUrl) {
447
+ if (repoUrl.startsWith("git@") || repoUrl.startsWith("ssh://")) {
448
+ return { cleanUrl: repoUrl };
449
+ }
450
+ try {
451
+ const { spawn: spawnProcess } = await import("node:child_process");
452
+ const url = new URL(repoUrl);
453
+ const input = `protocol=${url.protocol.replace(":", "")}\nhost=${url.hostname}\n\n`;
454
+ const result = await new Promise((resolvePromise, reject) => {
455
+ const proc = spawnProcess("git", ["credential", "fill"], {
456
+ stdio: ["pipe", "pipe", "pipe"],
457
+ timeout: 10_000,
458
+ });
459
+ let stdout = "";
460
+ proc.stdout.on("data", (d) => {
461
+ stdout += d.toString();
462
+ });
463
+ proc.on("close", (code) => {
464
+ if (code === 0)
465
+ resolvePromise(stdout);
466
+ else
467
+ reject(new Error(`git credential fill exited ${code}`));
468
+ });
469
+ proc.on("error", reject);
470
+ proc.stdin.write(input);
471
+ proc.stdin.end();
472
+ });
473
+ let username = "";
474
+ let password = "";
475
+ for (const line of result.split("\n")) {
476
+ if (line.startsWith("username="))
477
+ username = line.slice(9);
478
+ if (line.startsWith("password="))
479
+ password = line.slice(9);
480
+ }
481
+ if (username && password) {
482
+ // Return the ORIGINAL url untouched (no userinfo) plus the credential
483
+ // separately — the credential is injected via an env-var credential
484
+ // helper at git-command time, never in the URL or .git/config.
485
+ return { cleanUrl: repoUrl, credentials: { username, password } };
486
+ }
487
+ }
488
+ catch {
489
+ /* No credentials available from git credential helper */
490
+ }
491
+ return { cleanUrl: repoUrl };
492
+ }
493
+ }
494
+ //# sourceMappingURL=session-manager.js.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Session snapshot: save and restore Claude Code session files for workflow resume.
3
+ *
4
+ * Before destroying a container after idle timeout:
5
+ * 1. Auto-save code: git add -A && git commit && git push
6
+ * 2. Tar Claude Code session files (~/.claude/projects/)
7
+ * 3. Upload encrypted snapshot to Cloud via socket event
8
+ *
9
+ * On resume in a new container:
10
+ * 1. Download snapshot from Cloud
11
+ * 2. Restore Claude Code session files
12
+ * 3. Launch `claude --continue` (full context restored)
13
+ */
14
+ import { type GitCredentials } from "./git-credentials.js";
15
+ import type { SessionState } from "./session-manager.js";
16
+ /**
17
+ * Auto-save code changes in the container (best-effort git commit + push).
18
+ * Does not throw — failures are logged and ignored.
19
+ */
20
+ export declare function autoSaveCode(containerId: string, sessionId?: string, credentials?: GitCredentials): Promise<void>;
21
+ /**
22
+ * Save Claude Code session files from the container to a base64 tar.gz string.
23
+ *
24
+ * Returns the base64-encoded tar.gz, or null if snapshot is unavailable or too large.
25
+ */
26
+ export declare function createSessionSnapshot(session: SessionState): Promise<string | null>;
27
+ /**
28
+ * Restore Claude Code session files into a container from a base64 tar.gz string.
29
+ */
30
+ export declare function restoreSessionSnapshot(session: SessionState, snapshotBase64: string): Promise<boolean>;
31
+ //# sourceMappingURL=session-snapshot.d.ts.map