@companyhelm/cli 0.0.2 → 0.0.5

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 (47) hide show
  1. package/README.md +24 -62
  2. package/RUNTIME_IMAGE_VERSION +1 -1
  3. package/dist/cli.js +29 -1
  4. package/dist/commands/register-commands.js +2 -0
  5. package/dist/commands/root.js +280 -20
  6. package/dist/commands/startup.js +138 -55
  7. package/dist/commands/status.js +32 -0
  8. package/dist/service/app_server.js +23 -9
  9. package/dist/service/docker/app_server_container.js +3 -1
  10. package/dist/service/thread_lifecycle.js +4 -1
  11. package/dist/state/daemon_state.js +83 -0
  12. package/dist/state/schema.js +9 -1
  13. package/dist/templates/app_server_bootstrap.sh.j2 +46 -0
  14. package/dist/templates/runtime_agents.md.j2 +50 -0
  15. package/dist/templates/runtime_bashrc.j2 +19 -0
  16. package/dist/utils/daemon.js +15 -0
  17. package/dist/utils/process.js +22 -0
  18. package/drizzle/0011_actual_lucky.sql +7 -0
  19. package/drizzle/meta/_journal.json +8 -1
  20. package/package.json +6 -3
  21. package/dist/commands/agent/index.js +0 -10
  22. package/dist/commands/agent/list.js +0 -31
  23. package/dist/commands/agent/register-agent-commands.js +0 -10
  24. package/dist/commands/index.js +0 -15
  25. package/dist/commands/sdk/index.js +0 -12
  26. package/dist/commands/thread/index.js +0 -12
  27. package/dist/config/local.js +0 -1
  28. package/dist/config/schema.js +0 -7
  29. package/dist/model.js +0 -22
  30. package/dist/schema.js +0 -47
  31. package/dist/service/docker/docker_provider.js +0 -1
  32. package/dist/service/docker/runtime_container.js +0 -1
  33. package/dist/service/docker/runtime_image.js +0 -40
  34. package/dist/startup.js +0 -166
  35. package/dist/state/service/app_server.js +0 -392
  36. package/dist/state/service/buffered_client_message_sender.js +0 -73
  37. package/dist/state/service/companyhelm_api_client.js +0 -316
  38. package/dist/state/service/docker/app_server_container.js +0 -165
  39. package/dist/state/service/docker/dind.js +0 -114
  40. package/dist/state/service/docker/runtime_app_server_exec.js +0 -95
  41. package/dist/state/service/host.js +0 -15
  42. package/dist/state/service/runtime_shell.js +0 -23
  43. package/dist/state/service/sdk/refresh_models.js +0 -83
  44. package/dist/state/service/thread_lifecycle.js +0 -327
  45. package/dist/state/service/thread_runtime.js +0 -11
  46. package/dist/state/service/thread_turn_state.js +0 -45
  47. package/dist/state/service/workspace_agents.js +0 -115
package/README.md CHANGED
@@ -1,95 +1,57 @@
1
- # CompanyHelm Runner
1
+ # CompanyHelm CLI
2
2
 
3
- Run coding agents in yolo mode inside secure Docker containers, locally.
4
-
5
- Features:
6
-
7
- - Secure agent containers: no risk of agents going rogue on your main file system
8
- - Yolo mode: no more permission prompts
9
- - DinD (Docker-in-Docker): allows agents to spin up your services (backend, frontend, etc.) and test end-to-end
10
- - Multi-agent support: each agent gets its own environment and can operate autonomously
11
-
12
- ---
13
-
14
- ## Why CompanyHelm Runner?
15
-
16
- Modern coding agents are powerful, but they often run directly on your machine.
17
-
18
- CompanyHelm Runner adds:
19
-
20
- - Isolation
21
- - Docker-in-Docker (DIND)
22
- - Clean workspace lifecycle
23
-
24
- Think:
25
-
26
- Codex or Claude Code, but inside a sandbox you control.
27
-
28
- ---
3
+ Run coding agents in isolated Docker sandboxes on your machine.
29
4
 
30
5
  ## Install
31
6
 
32
7
  ```bash
33
- npm install -g companyhelm
8
+ npm install -g @companyhelm/cli
34
9
  ```
35
10
 
36
- Or run directly:
11
+ Or run it without installing globally:
37
12
 
38
13
  ```bash
39
- npx companyhelm
14
+ npx @companyhelm/cli --help
40
15
  ```
41
16
 
42
- ---
17
+ Package: [@companyhelm/cli](https://www.npmjs.com/package/@companyhelm/cli)
43
18
 
44
- ## Quick Start
19
+ ## Basic Usage
45
20
 
46
- Run companyhelm runner inside your workspace:
21
+ Start the CLI in the foreground:
47
22
 
48
23
  ```bash
49
- companyhelm
24
+ companyhelm-runner
50
25
  ```
51
26
 
52
- ## Database Migrations (Drizzle Kit)
53
-
54
- Generate SQL migrations from the schema:
55
-
56
- ```bash
57
- npm run db:generate
58
- ```
59
-
60
- Apply migrations directly with Drizzle Kit (defaults to `~/.local/share/companyhelm/state.db`):
27
+ Start it as a daemon:
61
28
 
62
29
  ```bash
63
- npm run db:migrate
30
+ companyhelm-runner --daemon
64
31
  ```
65
32
 
66
- Override the migration target database path when needed:
33
+ Check whether the daemon is running:
67
34
 
68
35
  ```bash
69
- DRIZZLE_DB_PATH=/absolute/path/to/state.db npm run db:migrate
36
+ companyhelm-runner status
70
37
  ```
71
38
 
72
- ## Thread-Level MCP E2E Check
39
+ The `status` command prints:
73
40
 
74
- Use the runtime helper to validate thread-level MCP end-to-end behavior for:
41
+ - whether the daemon is running
42
+ - the recorded daemon PID
43
+ - the daemon log directory and log file path
75
44
 
76
- - a local known-good stdio MCP server (`local_echo`)
77
- - Context7 stdio MCP (`resolve-library-id`, `query-docs`)
45
+ ## Why Use It
78
46
 
79
- Prerequisites:
80
-
81
- - CompanyHelm API is running and reachable at `http://127.0.0.1:4000/graphql` (or pass `--api-url`)
82
- - at least one connected runner for the target company with `codex` SDK and an available model
83
-
84
- Run:
85
-
86
- ```bash
87
- scripts/runtime/e2e-thread-mcp --company-id <company-id>
88
- ```
47
+ - Runs agents in isolated containers instead of directly on your machine
48
+ - Supports Docker-in-Docker for end-to-end workflows
49
+ - Keeps runner state in a local SQLite database
50
+ - Supports long-running daemon mode for background operation
89
51
 
90
- The script exits non-zero on failed assertions and prints a JSON summary on success, including created MCP/agent/thread IDs.
52
+ ## For Developers
91
53
 
92
- ---
54
+ Development and maintenance notes live in [DEVELOPING.md](./DEVELOPING.md).
93
55
 
94
56
  ## License
95
57
 
@@ -1 +1 @@
1
- 0.0.7
1
+ 0.0.8
package/dist/cli.js CHANGED
@@ -21,4 +21,32 @@ program
21
21
  .description("Run coding agents in fully isolated Docker sandboxes, locally.")
22
22
  .version(getVersion());
23
23
  (0, register_commands_js_1.registerCommands)(program);
24
- program.parse(process.argv);
24
+ function formatCliError(error) {
25
+ if (error instanceof commander_1.CommanderError) {
26
+ return {
27
+ message: error.message,
28
+ exitCode: error.exitCode,
29
+ };
30
+ }
31
+ if (error instanceof Error) {
32
+ return {
33
+ message: error.message,
34
+ exitCode: 1,
35
+ };
36
+ }
37
+ return {
38
+ message: String(error),
39
+ exitCode: 1,
40
+ };
41
+ }
42
+ async function main() {
43
+ try {
44
+ await program.parseAsync(process.argv);
45
+ }
46
+ catch (error) {
47
+ const { message, exitCode } = formatCliError(error);
48
+ process.stderr.write(`${message}\n`);
49
+ process.exitCode = exitCode;
50
+ }
51
+ }
52
+ void main();
@@ -4,9 +4,11 @@ exports.registerCommands = registerCommands;
4
4
  const root_js_1 = require("./root.js");
5
5
  const shell_js_1 = require("./shell.js");
6
6
  const register_sdk_commands_js_1 = require("./sdk/register-sdk-commands.js");
7
+ const status_js_1 = require("./status.js");
7
8
  const register_thread_commands_js_1 = require("./thread/register-thread-commands.js");
8
9
  function registerCommands(program) {
9
10
  (0, root_js_1.registerRootCommand)(program);
11
+ (0, status_js_1.registerStatusCommand)(program);
10
12
  (0, register_thread_commands_js_1.registerThreadCommands)(program);
11
13
  (0, shell_js_1.registerShellCommand)(program);
12
14
  (0, register_sdk_commands_js_1.registerSdkCommands)(program);
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isRetryableApiConnectionError = isRetryableApiConnectionError;
36
37
  exports.shouldUseTurnSteer = shouldUseTurnSteer;
37
38
  exports.isNoActiveTurnSteerError = isNoActiveTurnSteerError;
38
39
  exports.isNoRunningTurnInterruptError = isNoRunningTurnInterruptError;
@@ -40,11 +41,13 @@ exports.normalizeThreadAgentApiUrlForRuntime = normalizeThreadAgentApiUrlForRunt
40
41
  exports.extractThreadNameUpdateFromNotification = extractThreadNameUpdateFromNotification;
41
42
  exports.extractServerMessageRequestId = extractServerMessageRequestId;
42
43
  exports.runRootCommand = runRootCommand;
44
+ exports.buildRootConfig = buildRootConfig;
43
45
  exports.registerRootCommand = registerRootCommand;
44
46
  const protobuf_1 = require("@bufbuild/protobuf");
45
47
  const protos_1 = require("@companyhelm/protos");
46
48
  const drizzle_orm_1 = require("drizzle-orm");
47
49
  const grpc = __importStar(require("@grpc/grpc-js"));
50
+ const node_child_process_1 = require("node:child_process");
48
51
  const node_crypto_1 = require("node:crypto");
49
52
  const node_fs_1 = require("node:fs");
50
53
  const node_path_1 = require("node:path");
@@ -60,8 +63,10 @@ const thread_runtime_js_1 = require("../service/thread_runtime.js");
60
63
  const thread_turn_state_js_1 = require("../service/thread_turn_state.js");
61
64
  const thread_user_message_request_store_js_1 = require("../service/thread_user_message_request_store.js");
62
65
  const thread_lifecycle_js_1 = require("../service/thread_lifecycle.js");
66
+ const daemon_state_js_1 = require("../state/daemon_state.js");
63
67
  const db_js_1 = require("../state/db.js");
64
68
  const schema_js_1 = require("../state/schema.js");
69
+ const daemon_js_1 = require("../utils/daemon.js");
65
70
  const logger_js_1 = require("../utils/logger.js");
66
71
  const workspace_agents_js_1 = require("../service/workspace_agents.js");
67
72
  const COMMAND_CHANNEL_CONNECT_RETRY_DELAY_MS = 1000;
@@ -83,6 +88,7 @@ const YOLO_SANDBOX_MODE = "danger-full-access";
83
88
  const YOLO_SANDBOX_POLICY = { type: "dangerFullAccess" };
84
89
  const DOCKER_INTERNAL_HOSTNAME = "host.docker.internal";
85
90
  const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]);
91
+ const DAEMON_STARTUP_TIMEOUT_MS = 15000;
86
92
  const threadAppServerSessions = new Map();
87
93
  const threadRolloutPaths = new Map();
88
94
  function rememberThreadRolloutPath(threadId, rolloutPath) {
@@ -193,6 +199,26 @@ function normalizeReasoningLevels(value) {
193
199
  function toErrorMessage(error) {
194
200
  return error instanceof Error ? error.message : String(error);
195
201
  }
202
+ function getGrpcStatusCode(error) {
203
+ if (!error || typeof error !== "object" || !("code" in error)) {
204
+ return undefined;
205
+ }
206
+ const { code } = error;
207
+ return typeof code === "number" ? code : undefined;
208
+ }
209
+ function isRetryableApiConnectionError(error) {
210
+ return getGrpcStatusCode(error) !== grpc.status.UNAUTHENTICATED;
211
+ }
212
+ function toApiConnectionFailureMessage(error, secret) {
213
+ const message = toErrorMessage(error);
214
+ if (getGrpcStatusCode(error) !== grpc.status.UNAUTHENTICATED) {
215
+ return message;
216
+ }
217
+ if (!secret || secret.trim().length === 0) {
218
+ return `${message} Provide --secret <secret> to authenticate.`;
219
+ }
220
+ return message;
221
+ }
196
222
  function shouldUseTurnSteer(allowSteer, startedFromIdle) {
197
223
  return allowSteer && !startedFromIdle;
198
224
  }
@@ -202,6 +228,9 @@ function isNoActiveTurnSteerError(error) {
202
228
  function isNoRunningTurnInterruptError(error) {
203
229
  return /no running turn to interrupt/i.test(toErrorMessage(error));
204
230
  }
231
+ function isTurnCompletionTimeoutError(error) {
232
+ return /timed out waiting for completion of turn/i.test(toErrorMessage(error));
233
+ }
205
234
  function isRecord(value) {
206
235
  return typeof value === "object" && value !== null;
207
236
  }
@@ -1280,8 +1309,9 @@ async function sendRequestError(commandChannel, errorMessage, requestId) {
1280
1309
  });
1281
1310
  await commandChannel.send(message);
1282
1311
  }
1283
- async function sendThreadUpdate(commandChannel, threadId, status) {
1312
+ async function sendThreadUpdate(commandChannel, threadId, status, requestId) {
1284
1313
  const message = (0, protobuf_1.create)(protos_1.ClientMessageSchema, {
1314
+ requestId,
1285
1315
  payload: {
1286
1316
  case: "threadUpdate",
1287
1317
  value: {
@@ -1416,11 +1446,18 @@ async function resolveThreadAuthMode(cfg) {
1416
1446
  }
1417
1447
  async function handleCreateThreadRequest(cfg, commandChannel, request, requestId, apiClient, apiCallOptions, logger) {
1418
1448
  const threadId = (request.threadId ?? "").trim();
1449
+ const modelName = (request.model ?? "").trim();
1450
+ const requestedReasoningLevel = (request.reasoningLevel ?? "").trim();
1419
1451
  if (!threadId) {
1420
1452
  logger.warn("Rejecting createThreadRequest: threadId is required.");
1421
1453
  await sendRequestError(commandChannel, "Thread id is required.", requestId);
1422
1454
  return;
1423
1455
  }
1456
+ if (!modelName) {
1457
+ logger.warn("Rejecting createThreadRequest: model is required.");
1458
+ await sendRequestError(commandChannel, "Model is required.", requestId);
1459
+ return;
1460
+ }
1424
1461
  const { db, client } = await (0, db_js_1.initDb)(cfg.state_db_path);
1425
1462
  const threadDirectory = (0, thread_lifecycle_js_1.resolveThreadDirectory)(cfg.config_directory, cfg.workspaces_directory, threadId);
1426
1463
  const containerNames = (0, thread_lifecycle_js_1.buildThreadContainerNames)(threadId);
@@ -1429,16 +1466,40 @@ async function handleCreateThreadRequest(cfg, commandChannel, request, requestId
1429
1466
  const threadGitSkillPackages = normalizeThreadGitSkillPackagesForThreadConfig(request.gitSkillPackages, logger);
1430
1467
  const threadMcpServers = normalizeThreadMcpServersForThreadConfig(request.mcpServers, logger);
1431
1468
  const cliSecret = String(request.cliSecret ?? "").trim();
1432
- logger.debug(`Received createThreadRequest for thread '${threadId}' (model '${request.model}', reasoning '${request.reasoningLevel ?? ""}', additional instructions length '${normalizedAdditionalModelInstructions?.length ?? 0}', git skill packages '${threadGitSkillPackages.length}', MCP servers '${threadMcpServers.length}').`);
1469
+ logger.debug(`Received createThreadRequest for thread '${threadId}' (model '${modelName}', reasoning '${requestedReasoningLevel}', additional instructions length '${normalizedAdditionalModelInstructions?.length ?? 0}', git skill packages '${threadGitSkillPackages.length}', MCP servers '${threadMcpServers.length}').`);
1433
1470
  let authMode;
1434
1471
  try {
1435
1472
  authMode = await resolveThreadAuthMode(cfg);
1473
+ const modelConfig = await db
1474
+ .select({
1475
+ name: schema_js_1.llmModels.name,
1476
+ reasoningLevels: schema_js_1.llmModels.reasoningLevels,
1477
+ })
1478
+ .from(schema_js_1.llmModels)
1479
+ .where((0, drizzle_orm_1.eq)(schema_js_1.llmModels.name, modelName))
1480
+ .get();
1481
+ const configuredModelSample = await db
1482
+ .select({ name: schema_js_1.llmModels.name })
1483
+ .from(schema_js_1.llmModels)
1484
+ .limit(1)
1485
+ .all();
1486
+ if (configuredModelSample.length > 0) {
1487
+ if (!modelConfig) {
1488
+ throw new Error(`Model '${modelName}' is not configured.`);
1489
+ }
1490
+ if (requestedReasoningLevel.length > 0) {
1491
+ const supportedReasoningLevels = normalizeReasoningLevels(modelConfig.reasoningLevels);
1492
+ if (supportedReasoningLevels.length > 0 && !supportedReasoningLevels.includes(requestedReasoningLevel)) {
1493
+ throw new Error(`Reasoning level '${requestedReasoningLevel}' is not configured for model '${modelName}'.`);
1494
+ }
1495
+ }
1496
+ }
1436
1497
  await db.insert(schema_js_1.threads).values({
1437
1498
  id: threadId,
1438
1499
  sdkThreadId: null,
1439
1500
  cliSecret: cliSecret.length > 0 ? cliSecret : null,
1440
- model: request.model,
1441
- reasoningLevel: request.reasoningLevel ?? "",
1501
+ model: modelName,
1502
+ reasoningLevel: requestedReasoningLevel,
1442
1503
  additionalModelInstructions: normalizedAdditionalModelInstructions,
1443
1504
  status: "pending",
1444
1505
  currentSdkTurnId: null,
@@ -1508,9 +1569,73 @@ async function handleCreateThreadRequest(cfg, commandChannel, request, requestId
1508
1569
  await sendRequestError(commandChannel, `Failed to create containers for thread '${threadId}': ${toErrorMessage(error)}`, requestId);
1509
1570
  return;
1510
1571
  }
1572
+ let readyRequestId;
1511
1573
  const { db: updateDb, client: updateClient } = await (0, db_js_1.initDb)(cfg.state_db_path);
1512
1574
  try {
1513
- await updateDb.update(schema_js_1.threads).set({ status: "ready" }).where((0, drizzle_orm_1.eq)(schema_js_1.threads.id, threadId));
1575
+ const threadState = await (0, thread_turn_state_js_1.loadThreadMessageExecutionState)(cfg.state_db_path, threadId);
1576
+ if (!threadState) {
1577
+ throw new Error(`Thread '${threadId}' disappeared before SDK bootstrap.`);
1578
+ }
1579
+ const threadMcpSetup = buildThreadCodexMcpSetup(readWorkspaceThreadMcpConfig(threadState.workspace, logger));
1580
+ const threadAgentCliConfig = readWorkspaceThreadAgentCliConfig(threadState.workspace, logger);
1581
+ const appServerSession = await getOrCreateThreadAppServerSession(threadId, threadState.runtimeContainer, threadMcpSetup.appServerEnv, cfg.codex.app_server_client_name, logger);
1582
+ const runtimeUser = {
1583
+ uid: threadState.uid,
1584
+ gid: threadState.gid,
1585
+ agentUser: cfg.agent_user,
1586
+ agentHomeDirectory: threadState.homeDirectory,
1587
+ };
1588
+ await (0, thread_runtime_js_1.ensureThreadRuntimeReady)({
1589
+ dindContainer: threadState.dindContainer,
1590
+ runtimeContainer: threadState.runtimeContainer,
1591
+ containerService,
1592
+ gitUserName: cfg.git_user_name,
1593
+ gitUserEmail: cfg.git_user_email,
1594
+ user: runtimeUser,
1595
+ });
1596
+ await ensureThreadGitSkillsInRuntime(cfg, threadState, containerService, logger);
1597
+ if (threadAgentCliConfig) {
1598
+ await containerService.ensureRuntimeContainerAgentCliConfig(threadState.runtimeContainer, runtimeUser, threadAgentCliConfig);
1599
+ }
1600
+ if (!appServerSession.started) {
1601
+ await containerService.ensureRuntimeContainerCodexConfig(threadState.runtimeContainer, runtimeUser, threadMcpSetup.configToml);
1602
+ }
1603
+ await ensureThreadAppServerSessionStarted(appServerSession);
1604
+ const developerInstructions = buildThreadDeveloperInstructions(threadState.additionalModelInstructions);
1605
+ logger.debug(`Starting app-server thread '${threadId}' with developer instructions: ${JSON.stringify(developerInstructions)}.`);
1606
+ const threadStartResponse = await appServerSession.appServer.startThreadWithResponse({
1607
+ model: threadState.model,
1608
+ modelProvider: null,
1609
+ cwd: "/workspace",
1610
+ approvalPolicy: YOLO_APPROVAL_POLICY,
1611
+ sandbox: YOLO_SANDBOX_MODE,
1612
+ config: null,
1613
+ baseInstructions: null,
1614
+ developerInstructions,
1615
+ personality: null,
1616
+ ephemeral: null,
1617
+ experimentalRawEvents: false,
1618
+ persistExtendedHistory: true,
1619
+ }, requestId);
1620
+ if (requestId && threadStartResponse.id !== requestId) {
1621
+ throw new Error(`App-server thread/start response id '${String(threadStartResponse.id)}' did not match runner request id '${requestId}'.`);
1622
+ }
1623
+ if (!threadStartResponse.result.thread?.id) {
1624
+ throw new Error(`App-server thread/start did not return an SDK thread id for thread '${threadId}'.`);
1625
+ }
1626
+ readyRequestId = typeof threadStartResponse.id === "string" && threadStartResponse.id.length > 0
1627
+ ? threadStartResponse.id
1628
+ : undefined;
1629
+ appServerSession.sdkThreadId = threadStartResponse.result.thread.id;
1630
+ appServerSession.rolloutPath = threadStartResponse.result.thread.path;
1631
+ rememberThreadRolloutPath(threadId, threadStartResponse.result.thread.path);
1632
+ await updateDb
1633
+ .update(schema_js_1.threads)
1634
+ .set({
1635
+ status: "ready",
1636
+ sdkThreadId: threadStartResponse.result.thread.id,
1637
+ })
1638
+ .where((0, drizzle_orm_1.eq)(schema_js_1.threads.id, threadId));
1514
1639
  }
1515
1640
  catch (error) {
1516
1641
  logger.warn(`Failed to mark thread '${threadId}' as ready: ${toErrorMessage(error)}`);
@@ -1527,7 +1652,7 @@ async function handleCreateThreadRequest(cfg, commandChannel, request, requestId
1527
1652
  updateClient.close();
1528
1653
  }
1529
1654
  logger.info(`Thread '${threadId}' created and ready.`);
1530
- await sendThreadUpdate(commandChannel, threadId, protos_1.ThreadStatus.READY);
1655
+ await sendThreadUpdate(commandChannel, threadId, protos_1.ThreadStatus.READY, readyRequestId);
1531
1656
  }
1532
1657
  async function deleteThreadWithCleanup(cfg, request) {
1533
1658
  const { db, client } = await (0, db_js_1.initDb)(cfg.state_db_path);
@@ -1742,6 +1867,7 @@ async function executeCreateUserMessageRequest(cfg, commandChannel, request, req
1742
1867
  let keepRuntimeWarm = false;
1743
1868
  let shouldTrackTurnCompletion = trackTurnCompletion;
1744
1869
  let enqueuedRequestTurnId = null;
1870
+ let turnCompletionWaitStarted = false;
1745
1871
  try {
1746
1872
  await (0, thread_runtime_js_1.ensureThreadRuntimeReady)({
1747
1873
  dindContainer: threadState.dindContainer,
@@ -1827,14 +1953,18 @@ async function executeCreateUserMessageRequest(cfg, commandChannel, request, req
1827
1953
  if (!threadState.currentSdkTurnId) {
1828
1954
  throw new Error(`Thread '${request.threadId}' is marked running but has no current SDK turn id.`);
1829
1955
  }
1956
+ const activeSdkTurnId = threadState.currentSdkTurnId;
1830
1957
  const steerParams = {
1831
1958
  threadId: resolvedSdkThreadId,
1832
1959
  input,
1833
- expectedTurnId: threadState.currentSdkTurnId,
1960
+ expectedTurnId: activeSdkTurnId,
1834
1961
  };
1835
1962
  try {
1836
1963
  const turnSteerResult = await appServer.steerTurn(steerParams);
1837
- sdkTurnId = turnSteerResult.turnId;
1964
+ if (turnSteerResult.turnId && turnSteerResult.turnId !== activeSdkTurnId) {
1965
+ logger.debug(`turn/steer returned turn '${turnSteerResult.turnId}' for thread '${request.threadId}', preserving active turn '${activeSdkTurnId}' as the canonical turn id.`);
1966
+ }
1967
+ sdkTurnId = activeSdkTurnId;
1838
1968
  }
1839
1969
  catch (error) {
1840
1970
  if (!isNoActiveTurnSteerError(error)) {
@@ -1864,6 +1994,7 @@ async function executeCreateUserMessageRequest(cfg, commandChannel, request, req
1864
1994
  keepRuntimeWarm = true;
1865
1995
  return;
1866
1996
  }
1997
+ turnCompletionWaitStarted = true;
1867
1998
  const terminalStatus = await waitForThreadTurnCompletion(cfg.state_db_path, appServer, commandChannel, request.threadId, sdkThreadId, sdkTurnId, logger, requestId);
1868
1999
  await updateThreadTurnState(cfg, request.threadId, {
1869
2000
  currentSdkTurnId: sdkTurnId,
@@ -1886,7 +2017,12 @@ async function executeCreateUserMessageRequest(cfg, commandChannel, request, req
1886
2017
  if (enqueuedRequestTurnId && requestId) {
1887
2018
  await (0, thread_user_message_request_store_js_1.removePendingUserMessageRequestIdForTurn)(cfg.state_db_path, request.threadId, enqueuedRequestTurnId, requestId);
1888
2019
  }
1889
- if (startedFromIdle && !turnAccepted) {
2020
+ if (turnCompletionWaitStarted && !isTurnCompletionTimeoutError(error)) {
2021
+ await updateThreadTurnState(cfg, request.threadId, {
2022
+ isCurrentTurnRunning: false,
2023
+ }).catch(() => undefined);
2024
+ }
2025
+ else if (startedFromIdle && !turnAccepted) {
1890
2026
  await updateThreadTurnState(cfg, request.threadId, {
1891
2027
  isCurrentTurnRunning: false,
1892
2028
  }).catch(() => undefined);
@@ -1979,25 +2115,109 @@ function buildGrpcAuthCallOptions(secret) {
1979
2115
  metadata.set("authorization", `Bearer ${secret}`);
1980
2116
  return { metadata };
1981
2117
  }
1982
- async function runRootCommand(options) {
2118
+ function isInternalDaemonChildProcess() {
2119
+ return process.env[daemon_js_1.DAEMON_CHILD_ENV] === "1";
2120
+ }
2121
+ function resolveEffectiveDaemonLogPath(cfg) {
2122
+ const envPath = process.env[daemon_js_1.DAEMON_LOG_PATH_ENV];
2123
+ if (envPath && envPath.trim().length > 0) {
2124
+ return envPath;
2125
+ }
2126
+ return (0, daemon_js_1.resolveDaemonLogPath)(cfg.state_db_path);
2127
+ }
2128
+ async function runDetachedDaemonProcess(options) {
2129
+ const cfg = buildRootConfig(options);
2130
+ const logPath = (0, daemon_js_1.resolveDaemonLogPath)(cfg.state_db_path);
2131
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(logPath), { recursive: true });
2132
+ const logFd = (0, node_fs_1.openSync)(logPath, "a");
2133
+ try {
2134
+ await new Promise((resolve, reject) => {
2135
+ const child = (0, node_child_process_1.spawn)(process.execPath, process.argv.slice(1), {
2136
+ cwd: process.cwd(),
2137
+ detached: true,
2138
+ env: {
2139
+ ...process.env,
2140
+ [daemon_js_1.DAEMON_CHILD_ENV]: "1",
2141
+ [daemon_js_1.DAEMON_LOG_PATH_ENV]: logPath,
2142
+ },
2143
+ stdio: ["ignore", logFd, logFd, "ipc"],
2144
+ windowsHide: true,
2145
+ });
2146
+ let settled = false;
2147
+ const timeout = setTimeout(() => {
2148
+ if (settled) {
2149
+ return;
2150
+ }
2151
+ settled = true;
2152
+ child.kill();
2153
+ reject(new Error(`Timed out waiting for daemon startup confirmation. See ${logPath}.`));
2154
+ }, DAEMON_STARTUP_TIMEOUT_MS);
2155
+ const finish = (callback) => {
2156
+ if (settled) {
2157
+ return;
2158
+ }
2159
+ settled = true;
2160
+ clearTimeout(timeout);
2161
+ callback();
2162
+ };
2163
+ child.once("error", (error) => {
2164
+ finish(() => reject(error));
2165
+ });
2166
+ child.once("exit", (code, signal) => {
2167
+ finish(() => {
2168
+ reject(new Error(`Daemon exited before startup completed (code=${code ?? "null"}, signal=${signal ?? "null"}). See ${logPath}.`));
2169
+ });
2170
+ });
2171
+ child.on("message", (message) => {
2172
+ if (!message || typeof message !== "object" || !("type" in message)) {
2173
+ return;
2174
+ }
2175
+ const type = message.type;
2176
+ if (type === "daemon-ready") {
2177
+ finish(() => {
2178
+ if (child.connected) {
2179
+ child.disconnect();
2180
+ }
2181
+ child.unref();
2182
+ console.log(`CompanyHelm daemon started (pid ${child.pid}). Logs: ${logPath}`);
2183
+ resolve();
2184
+ });
2185
+ return;
2186
+ }
2187
+ if (type === "daemon-error") {
2188
+ const messageValue = message.message;
2189
+ const daemonErrorMessage = typeof messageValue === "string" ? messageValue : `Daemon startup failed. See ${logPath}.`;
2190
+ finish(() => reject(new Error(daemonErrorMessage)));
2191
+ }
2192
+ });
2193
+ });
2194
+ }
2195
+ finally {
2196
+ (0, node_fs_1.closeSync)(logFd);
2197
+ }
2198
+ }
2199
+ function sendDaemonParentMessage(message) {
2200
+ if (typeof process.send === "function") {
2201
+ process.send(message);
2202
+ }
2203
+ }
2204
+ async function runRootCommand(options, runtimeOptions) {
1983
2205
  const logger = (0, logger_js_1.createLogger)(options.logLevel ?? "INFO", { daemonMode: options.daemon ?? false });
1984
- const cfg = config_js_1.config.parse({
1985
- companyhelm_api_url: options.serverUrl,
1986
- agent_api_url: options.agentApiUrl,
1987
- use_host_docker_runtime: options.useHostDockerRuntime,
1988
- host_docker_path: options.hostDockerPath,
1989
- thread_git_skills_directory: options.threadGitSkillsDirectory,
1990
- });
2206
+ const cfg = buildRootConfig(options);
1991
2207
  const configuredSdks = await hasConfiguredSdks(cfg);
1992
2208
  if (!configuredSdks && options.daemon) {
1993
2209
  throw new Error("No SDKs configured. Daemon mode requires at least one configured SDK.");
1994
2210
  }
1995
2211
  if (!configuredSdks) {
1996
- await (0, startup_js_1.startup)();
2212
+ await (0, startup_js_1.startup)(cfg);
1997
2213
  }
1998
2214
  await refreshCodexModelsForRegistration(cfg, logger);
1999
2215
  const registerRequest = await buildRegisterRunnerRequest(cfg);
2000
2216
  const apiCallOptions = buildGrpcAuthCallOptions(options.secret);
2217
+ if (options.daemon) {
2218
+ await (0, daemon_state_js_1.claimCurrentDaemonState)(cfg.state_db_path, process.pid, resolveEffectiveDaemonLogPath(cfg));
2219
+ runtimeOptions?.onDaemonReady?.();
2220
+ }
2001
2221
  const commandMessageSink = new buffered_client_message_sender_js_1.BufferedClientMessageSender({
2002
2222
  maxBufferedEvents: cfg.client_message_buffer_limit,
2003
2223
  logger,
@@ -2032,7 +2252,10 @@ async function runRootCommand(options) {
2032
2252
  logger.warn("CompanyHelm API command channel closed. Reconnecting...");
2033
2253
  }
2034
2254
  catch (error) {
2035
- const failureMessage = toErrorMessage(error);
2255
+ const failureMessage = toApiConnectionFailureMessage(error, options.secret);
2256
+ if (!isRetryableApiConnectionError(error)) {
2257
+ throw new Error(failureMessage);
2258
+ }
2036
2259
  logger.warn(`CompanyHelm API connection attempt ${reconnectAttempt} failed: ${failureMessage}. ` +
2037
2260
  "Retrying...");
2038
2261
  }
@@ -2057,21 +2280,58 @@ async function runRootCommand(options) {
2057
2280
  if (droppedMessages > 0) {
2058
2281
  logger.warn(`Dropped ${droppedMessages} outbound client message(s) while command channel was disconnected.`);
2059
2282
  }
2283
+ if (options.daemon) {
2284
+ await (0, daemon_state_js_1.clearCurrentDaemonState)(cfg.state_db_path, process.pid).catch((error) => {
2285
+ logger.warn(`Failed to clear daemon state: ${toErrorMessage(error)}`);
2286
+ });
2287
+ }
2060
2288
  await stopAllThreadAppServerSessions();
2061
2289
  await stopAllThreadContainers(cfg, logger);
2062
2290
  }
2063
2291
  }
2292
+ function buildRootConfig(options) {
2293
+ return config_js_1.config.parse({
2294
+ config_directory: options.configPath,
2295
+ state_db_path: options.stateDbPath,
2296
+ companyhelm_api_url: options.serverUrl,
2297
+ agent_api_url: options.agentApiUrl,
2298
+ use_host_docker_runtime: options.useHostDockerRuntime,
2299
+ host_docker_path: options.hostDockerPath,
2300
+ thread_git_skills_directory: options.threadGitSkillsDirectory,
2301
+ });
2302
+ }
2064
2303
  function registerRootCommand(program) {
2065
2304
  program
2305
+ .option("--config-path <path>", "Config directory override (defaults to ~/.config/companyhelm).")
2066
2306
  .option("--server-url <url>", "CompanyHelm gRPC API URL override.")
2067
2307
  .option("--agent-api-url <url>", "Agent gRPC API URL for companyhelm-agent in runtime containers (localhost is rewritten to http://host.docker.internal).")
2068
2308
  .option("--secret <secret>", "Bearer secret used as gRPC Authorization header.")
2309
+ .option("--state-db-path <path>", "State database path override (defaults to ~/.local/share/companyhelm/state.db).")
2069
2310
  .option("--use-host-docker-runtime", "Mount host Docker socket into runtime containers instead of creating DinD sidecars.")
2070
2311
  .option("--host-docker-path <path>", "Host Docker endpoint when --use-host-docker-runtime is enabled (unix:///<socket-path> or tcp://localhost:<port>).")
2071
2312
  .option("--thread-git-skills-directory <path>", "Container path where thread git skill repositories are cloned before linking into ~/.codex/skills.")
2072
2313
  .option("-d, --daemon", "Run in daemon mode and fail fast when no SDK is configured.")
2073
2314
  .option("--log-level <level>", "Log level (DEBUG, INFO, WARN, ERROR).", "INFO")
2074
2315
  .action(async () => {
2075
- await runRootCommand(program.opts());
2316
+ const options = program.opts();
2317
+ if (options.daemon && !isInternalDaemonChildProcess()) {
2318
+ await runDetachedDaemonProcess(options);
2319
+ return;
2320
+ }
2321
+ try {
2322
+ await runRootCommand(options, isInternalDaemonChildProcess()
2323
+ ? {
2324
+ onDaemonReady: () => {
2325
+ sendDaemonParentMessage({ type: "daemon-ready" });
2326
+ },
2327
+ }
2328
+ : undefined);
2329
+ }
2330
+ catch (error) {
2331
+ if (isInternalDaemonChildProcess()) {
2332
+ sendDaemonParentMessage({ type: "daemon-error", message: toErrorMessage(error) });
2333
+ }
2334
+ throw error;
2335
+ }
2076
2336
  });
2077
2337
  }