@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.
- package/README.md +24 -62
- package/RUNTIME_IMAGE_VERSION +1 -1
- package/dist/cli.js +29 -1
- package/dist/commands/register-commands.js +2 -0
- package/dist/commands/root.js +280 -20
- package/dist/commands/startup.js +138 -55
- package/dist/commands/status.js +32 -0
- package/dist/service/app_server.js +23 -9
- package/dist/service/docker/app_server_container.js +3 -1
- package/dist/service/thread_lifecycle.js +4 -1
- package/dist/state/daemon_state.js +83 -0
- package/dist/state/schema.js +9 -1
- package/dist/templates/app_server_bootstrap.sh.j2 +46 -0
- package/dist/templates/runtime_agents.md.j2 +50 -0
- package/dist/templates/runtime_bashrc.j2 +19 -0
- package/dist/utils/daemon.js +15 -0
- package/dist/utils/process.js +22 -0
- package/drizzle/0011_actual_lucky.sql +7 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +6 -3
- package/dist/commands/agent/index.js +0 -10
- package/dist/commands/agent/list.js +0 -31
- package/dist/commands/agent/register-agent-commands.js +0 -10
- package/dist/commands/index.js +0 -15
- package/dist/commands/sdk/index.js +0 -12
- package/dist/commands/thread/index.js +0 -12
- package/dist/config/local.js +0 -1
- package/dist/config/schema.js +0 -7
- package/dist/model.js +0 -22
- package/dist/schema.js +0 -47
- package/dist/service/docker/docker_provider.js +0 -1
- package/dist/service/docker/runtime_container.js +0 -1
- package/dist/service/docker/runtime_image.js +0 -40
- package/dist/startup.js +0 -166
- package/dist/state/service/app_server.js +0 -392
- package/dist/state/service/buffered_client_message_sender.js +0 -73
- package/dist/state/service/companyhelm_api_client.js +0 -316
- package/dist/state/service/docker/app_server_container.js +0 -165
- package/dist/state/service/docker/dind.js +0 -114
- package/dist/state/service/docker/runtime_app_server_exec.js +0 -95
- package/dist/state/service/host.js +0 -15
- package/dist/state/service/runtime_shell.js +0 -23
- package/dist/state/service/sdk/refresh_models.js +0 -83
- package/dist/state/service/thread_lifecycle.js +0 -327
- package/dist/state/service/thread_runtime.js +0 -11
- package/dist/state/service/thread_turn_state.js +0 -45
- package/dist/state/service/workspace_agents.js +0 -115
package/dist/commands/startup.js
CHANGED
|
@@ -53,97 +53,184 @@ function banner() {
|
|
|
53
53
|
console.log(figlet_1.default.textSync("CompanyHelm", { font: "Small" }));
|
|
54
54
|
console.log();
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
function isErrnoException(error) {
|
|
57
|
+
return error instanceof Error && "code" in error;
|
|
58
|
+
}
|
|
59
|
+
function buildDockerMissingError() {
|
|
60
|
+
return new Error("Docker is not installed or not available on PATH. Install Docker and retry.");
|
|
61
|
+
}
|
|
62
|
+
const defaultStartupDependencies = {
|
|
63
|
+
getHostInfoFn: host_js_1.getHostInfo,
|
|
64
|
+
initDbFn: db_js_1.initDb,
|
|
65
|
+
promptApi: p,
|
|
66
|
+
refreshSdkModelsFn: refresh_models_js_1.refreshSdkModels,
|
|
67
|
+
spawnCommand: node_child_process_1.spawn,
|
|
68
|
+
spawnSyncCommand: node_child_process_1.spawnSync,
|
|
69
|
+
};
|
|
70
|
+
function ensureDockerAvailable(spawnSyncCommand) {
|
|
71
|
+
const result = spawnSyncCommand("docker", ["--version"], { stdio: "ignore" });
|
|
72
|
+
if (isErrnoException(result.error) && result.error.code === "ENOENT") {
|
|
73
|
+
throw buildDockerMissingError();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function restoreInteractiveTerminalState() {
|
|
77
|
+
try {
|
|
78
|
+
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
|
|
79
|
+
process.stdin.setRawMode(false);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Best-effort prompt cleanup.
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
if (process.stdout.isTTY) {
|
|
87
|
+
process.stdout.write("\u001B[?25h");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Best-effort prompt cleanup.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function exitStartup(code) {
|
|
95
|
+
restoreInteractiveTerminalState();
|
|
96
|
+
process.exit(code);
|
|
97
|
+
}
|
|
98
|
+
async function refreshCodexModelsInStartup(deps) {
|
|
99
|
+
ensureDockerAvailable(deps.spawnSyncCommand);
|
|
100
|
+
const spinner = deps.promptApi.spinner();
|
|
58
101
|
const seenStatusMessages = new Set();
|
|
59
102
|
spinner.start("Preparing Codex runtime image and refreshing model catalog via app-server");
|
|
60
|
-
const results = await
|
|
103
|
+
const results = await deps.refreshSdkModelsFn({
|
|
61
104
|
sdk: "codex",
|
|
62
105
|
imageStatusReporter: (message) => {
|
|
63
106
|
if (seenStatusMessages.has(message)) {
|
|
64
107
|
return;
|
|
65
108
|
}
|
|
66
109
|
seenStatusMessages.add(message);
|
|
67
|
-
|
|
110
|
+
deps.promptApi.log.info(message);
|
|
68
111
|
},
|
|
69
112
|
});
|
|
70
113
|
const count = results[0]?.modelCount ?? 0;
|
|
71
114
|
spinner.stop(`Codex model catalog refreshed (${count} models).`);
|
|
72
115
|
}
|
|
73
|
-
async function dedicatedAuth(cfg, db) {
|
|
116
|
+
async function dedicatedAuth(cfg, db, deps) {
|
|
117
|
+
ensureDockerAvailable(deps.spawnSyncCommand);
|
|
74
118
|
const port = cfg.codex.codex_auth_port;
|
|
75
119
|
const socatPort = port + 1;
|
|
76
120
|
const containerName = `companyhelm-codex-auth-${Date.now()}`;
|
|
77
|
-
|
|
78
|
-
|
|
121
|
+
deps.promptApi.log.info("Starting Codex login inside a container...");
|
|
122
|
+
deps.promptApi.log.info("A browser URL will appear -- open it to complete authentication.");
|
|
79
123
|
const configDir = (0, path_js_1.expandHome)(cfg.config_directory);
|
|
80
124
|
if (!(0, node_fs_1.existsSync)(configDir)) {
|
|
81
125
|
(0, node_fs_1.mkdirSync)(configDir, { recursive: true });
|
|
82
126
|
}
|
|
83
127
|
const destPath = (0, node_path_1.join)(configDir, cfg.codex.codex_auth_file_path);
|
|
84
|
-
const child = (0, node_child_process_1.spawn)("docker", [
|
|
85
|
-
"run",
|
|
86
|
-
"-it",
|
|
87
|
-
"--name",
|
|
88
|
-
containerName,
|
|
89
|
-
"-p",
|
|
90
|
-
`${port}:${socatPort}`,
|
|
91
|
-
"--entrypoint",
|
|
92
|
-
"bash",
|
|
93
|
-
cfg.runtime_image,
|
|
94
|
-
"-c",
|
|
95
|
-
`source "$NVM_DIR/nvm.sh"; socat TCP-LISTEN:${socatPort},fork,bind=0.0.0.0,reuseaddr TCP:127.0.0.1:${port} 2>/dev/null & codex`,
|
|
96
|
-
], { stdio: "inherit" });
|
|
97
128
|
let authCopied = false;
|
|
98
129
|
await new Promise((resolve, reject) => {
|
|
99
|
-
|
|
100
|
-
|
|
130
|
+
let settled = false;
|
|
131
|
+
let poll;
|
|
132
|
+
const rejectOnce = (error) => {
|
|
133
|
+
if (settled) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
settled = true;
|
|
137
|
+
if (poll) {
|
|
138
|
+
clearInterval(poll);
|
|
139
|
+
}
|
|
140
|
+
deps.spawnSyncCommand("docker", ["rm", "-f", containerName], { stdio: "ignore" });
|
|
141
|
+
reject(error);
|
|
142
|
+
};
|
|
143
|
+
const resolveOnce = () => {
|
|
144
|
+
if (settled) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
settled = true;
|
|
148
|
+
if (poll) {
|
|
149
|
+
clearInterval(poll);
|
|
150
|
+
}
|
|
151
|
+
resolve();
|
|
152
|
+
};
|
|
153
|
+
const child = deps.spawnCommand("docker", [
|
|
154
|
+
"run",
|
|
155
|
+
"-it",
|
|
156
|
+
"--name",
|
|
157
|
+
containerName,
|
|
158
|
+
"-p",
|
|
159
|
+
`${port}:${socatPort}`,
|
|
160
|
+
"--entrypoint",
|
|
161
|
+
"bash",
|
|
162
|
+
cfg.runtime_image,
|
|
163
|
+
"-c",
|
|
164
|
+
`source "$NVM_DIR/nvm.sh"; socat TCP-LISTEN:${socatPort},fork,bind=0.0.0.0,reuseaddr TCP:127.0.0.1:${port} 2>/dev/null & codex`,
|
|
165
|
+
], { stdio: "inherit" });
|
|
166
|
+
child.on("error", (error) => {
|
|
167
|
+
if (isErrnoException(error) && error.code === "ENOENT") {
|
|
168
|
+
rejectOnce(buildDockerMissingError());
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
rejectOnce(new Error(`Failed to start Codex login container: ${error.message}`));
|
|
172
|
+
});
|
|
173
|
+
poll = setInterval(() => {
|
|
174
|
+
if (settled) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const check = deps.spawnSyncCommand("docker", ["exec", containerName, "sh", "-c", `test -f ${cfg.codex.codex_auth_path}`], {
|
|
101
178
|
stdio: "ignore",
|
|
102
179
|
});
|
|
103
180
|
if (check.status === 0) {
|
|
104
|
-
|
|
105
|
-
const resolveResult = (0, node_child_process_1.spawnSync)("docker", ["exec", containerName, "sh", "-c", `echo ${cfg.codex.codex_auth_path}`], {
|
|
181
|
+
const resolveResult = deps.spawnSyncCommand("docker", ["exec", containerName, "sh", "-c", `echo ${cfg.codex.codex_auth_path}`], {
|
|
106
182
|
encoding: "utf-8",
|
|
107
183
|
});
|
|
108
184
|
const containerAuthAbsPath = resolveResult.stdout.trim();
|
|
109
|
-
const cpResult =
|
|
185
|
+
const cpResult = deps.spawnSyncCommand("docker", ["cp", `${containerName}:${containerAuthAbsPath}`, destPath], {
|
|
110
186
|
stdio: "ignore",
|
|
111
187
|
});
|
|
112
188
|
if (cpResult.status !== 0) {
|
|
113
|
-
(
|
|
114
|
-
reject(new Error("Failed to extract auth file from container."));
|
|
189
|
+
rejectOnce(new Error("Failed to extract auth file from container."));
|
|
115
190
|
return;
|
|
116
191
|
}
|
|
117
192
|
authCopied = true;
|
|
118
|
-
|
|
119
|
-
|
|
193
|
+
deps.spawnSyncCommand("docker", ["rm", "-f", containerName], { stdio: "ignore" });
|
|
194
|
+
resolveOnce();
|
|
120
195
|
}
|
|
121
196
|
}, 1000);
|
|
122
197
|
child.on("exit", () => {
|
|
123
|
-
clearInterval(poll);
|
|
124
198
|
if (!authCopied) {
|
|
125
|
-
(
|
|
126
|
-
reject(new Error("Codex login failed or was cancelled."));
|
|
199
|
+
rejectOnce(new Error("Codex login failed or was cancelled."));
|
|
127
200
|
}
|
|
128
201
|
});
|
|
129
202
|
});
|
|
130
203
|
await db.insert(schema_js_1.agentSdks).values({ name: "codex", authentication: "dedicated" });
|
|
131
|
-
|
|
204
|
+
deps.promptApi.log.success(`Codex auth saved to ${destPath}`);
|
|
132
205
|
}
|
|
133
|
-
async function
|
|
206
|
+
async function selectStartupAuthMode(options, deps) {
|
|
207
|
+
if (options.length === 1) {
|
|
208
|
+
return options[0].value;
|
|
209
|
+
}
|
|
210
|
+
const authMode = await deps.promptApi.select({
|
|
211
|
+
message: "How would you like to authenticate Codex?",
|
|
212
|
+
options,
|
|
213
|
+
});
|
|
214
|
+
if (deps.promptApi.isCancel(authMode)) {
|
|
215
|
+
deps.promptApi.cancel("Setup cancelled.");
|
|
216
|
+
exitStartup(0);
|
|
217
|
+
}
|
|
218
|
+
return authMode;
|
|
219
|
+
}
|
|
220
|
+
async function startup(cfg = config_js_1.config.parse({}), overrides = {}) {
|
|
221
|
+
const deps = { ...defaultStartupDependencies, ...overrides };
|
|
134
222
|
banner();
|
|
135
|
-
const
|
|
136
|
-
const s = p.spinner();
|
|
223
|
+
const s = deps.promptApi.spinner();
|
|
137
224
|
s.start("Initializing state database");
|
|
138
|
-
const { db } = await
|
|
225
|
+
const { db } = await deps.initDbFn(cfg.state_db_path);
|
|
139
226
|
s.stop("State database ready.");
|
|
140
227
|
const sdks = await db.select().from(schema_js_1.agentSdks).all();
|
|
141
228
|
if (sdks.length > 0) {
|
|
142
|
-
|
|
229
|
+
deps.promptApi.log.success(`Agent SDK configured: ${sdks.map((sdk) => sdk.name).join(", ")}`);
|
|
143
230
|
return;
|
|
144
231
|
}
|
|
145
|
-
|
|
146
|
-
const hostInfo =
|
|
232
|
+
deps.promptApi.intro("No agent SDK configured. Let's set up Codex authentication.");
|
|
233
|
+
const hostInfo = deps.getHostInfoFn(cfg.codex.codex_auth_path);
|
|
147
234
|
const options = [
|
|
148
235
|
{
|
|
149
236
|
value: "dedicated",
|
|
@@ -158,28 +245,24 @@ async function startup() {
|
|
|
158
245
|
hint: `reuse existing credentials from ${cfg.codex.codex_auth_path}`,
|
|
159
246
|
});
|
|
160
247
|
}
|
|
161
|
-
const authMode = await p.select({
|
|
162
|
-
message: "How would you like to authenticate Codex?",
|
|
163
|
-
options,
|
|
164
|
-
});
|
|
165
|
-
if (p.isCancel(authMode)) {
|
|
166
|
-
p.cancel("Setup cancelled.");
|
|
167
|
-
process.exit(0);
|
|
168
|
-
}
|
|
169
248
|
try {
|
|
249
|
+
const authMode = await selectStartupAuthMode(options, deps);
|
|
170
250
|
if (authMode === "host") {
|
|
171
251
|
await db.insert(schema_js_1.agentSdks).values({ name: "codex", authentication: "host" });
|
|
172
|
-
await refreshCodexModelsInStartup();
|
|
173
|
-
|
|
252
|
+
await refreshCodexModelsInStartup(deps);
|
|
253
|
+
deps.promptApi.outro("Codex SDK configured with host authentication.");
|
|
174
254
|
return;
|
|
175
255
|
}
|
|
176
|
-
await dedicatedAuth(cfg, db);
|
|
177
|
-
await refreshCodexModelsInStartup();
|
|
178
|
-
|
|
256
|
+
await dedicatedAuth(cfg, db, deps);
|
|
257
|
+
await refreshCodexModelsInStartup(deps);
|
|
258
|
+
deps.promptApi.outro("Codex login successful!");
|
|
179
259
|
}
|
|
180
260
|
catch (error) {
|
|
181
261
|
const message = error instanceof Error ? error.message : "Codex setup failed.";
|
|
182
|
-
|
|
183
|
-
|
|
262
|
+
deps.promptApi.cancel(message);
|
|
263
|
+
exitStartup(1);
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
restoreInteractiveTerminalState();
|
|
184
267
|
}
|
|
185
268
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerStatusCommand = registerStatusCommand;
|
|
4
|
+
const node_path_1 = require("node:path");
|
|
5
|
+
const config_js_1 = require("../config.js");
|
|
6
|
+
const daemon_state_js_1 = require("../state/daemon_state.js");
|
|
7
|
+
const daemon_js_1 = require("../utils/daemon.js");
|
|
8
|
+
const process_js_1 = require("../utils/process.js");
|
|
9
|
+
function registerStatusCommand(program) {
|
|
10
|
+
program
|
|
11
|
+
.command("status")
|
|
12
|
+
.description("Show whether the local CompanyHelm daemon is running.")
|
|
13
|
+
.option("--state-db-path <path>", "State database path override (defaults to ~/.local/share/companyhelm/state.db).")
|
|
14
|
+
.action(async (options) => {
|
|
15
|
+
const cfg = config_js_1.config.parse({
|
|
16
|
+
state_db_path: options.stateDbPath,
|
|
17
|
+
});
|
|
18
|
+
const state = await (0, daemon_state_js_1.readCurrentDaemonState)(cfg.state_db_path);
|
|
19
|
+
const running = state?.pid != null && (0, process_js_1.isProcessRunning)(state.pid);
|
|
20
|
+
const logPath = state?.logPath ?? (0, daemon_js_1.resolveDaemonLogPath)(cfg.state_db_path);
|
|
21
|
+
const logDirectory = state?.logPath ? (0, node_path_1.dirname)(state.logPath) : (0, daemon_js_1.resolveDaemonLogDirectory)(cfg.state_db_path);
|
|
22
|
+
console.log(`Daemon: ${running ? "running" : "not running"}`);
|
|
23
|
+
if (state?.pid != null) {
|
|
24
|
+
console.log(`PID: ${running ? state.pid : `${state.pid} (stale)`}`);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
console.log("PID: none");
|
|
28
|
+
}
|
|
29
|
+
console.log(`Log directory: ${logDirectory}`);
|
|
30
|
+
console.log(`Log file: ${logPath}`);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -104,6 +104,9 @@ class AppServerService {
|
|
|
104
104
|
async startThread(params) {
|
|
105
105
|
return this.request("thread/start", params, 15000);
|
|
106
106
|
}
|
|
107
|
+
async startThreadWithResponse(params, requestId) {
|
|
108
|
+
return this.requestWithResponse("thread/start", params, 15000, requestId);
|
|
109
|
+
}
|
|
107
110
|
async resumeThread(params) {
|
|
108
111
|
return this.request("thread/resume", params, 15000);
|
|
109
112
|
}
|
|
@@ -160,6 +163,9 @@ class AppServerService {
|
|
|
160
163
|
if (message.method === "error" &&
|
|
161
164
|
message.params.threadId === threadId &&
|
|
162
165
|
message.params.turnId === turnId) {
|
|
166
|
+
if (message.params.willRetry) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
163
169
|
throw new Error(message.params.error.message);
|
|
164
170
|
}
|
|
165
171
|
if (message.method === "turn/completed" &&
|
|
@@ -206,14 +212,22 @@ class AppServerService {
|
|
|
206
212
|
}
|
|
207
213
|
}
|
|
208
214
|
async request(method, params, timeoutMs) {
|
|
209
|
-
const
|
|
215
|
+
const response = await this.requestWithResponse(method, params, timeoutMs);
|
|
216
|
+
return response.result;
|
|
217
|
+
}
|
|
218
|
+
async requestWithResponse(method, params, timeoutMs, requestId) {
|
|
219
|
+
const resolvedRequestId = requestId ?? this.nextRequestId++;
|
|
210
220
|
const request = {
|
|
211
221
|
method,
|
|
212
|
-
id:
|
|
222
|
+
id: resolvedRequestId,
|
|
213
223
|
params,
|
|
214
224
|
};
|
|
215
225
|
await this.sendMessage(request);
|
|
216
|
-
|
|
226
|
+
const response = await this.waitForResponseMessage(resolvedRequestId, timeoutMs);
|
|
227
|
+
return {
|
|
228
|
+
id: response.id,
|
|
229
|
+
result: response.result,
|
|
230
|
+
};
|
|
217
231
|
}
|
|
218
232
|
async sendMessage(message) {
|
|
219
233
|
const payload = JSON.stringify(message);
|
|
@@ -369,7 +383,7 @@ class AppServerService {
|
|
|
369
383
|
pendingRequest.reject(new Error(`app-server returned an error for request ${String(message.id)}: ${formatUnknownError(message.error)}`));
|
|
370
384
|
return;
|
|
371
385
|
}
|
|
372
|
-
pendingRequest.resolve(message
|
|
386
|
+
pendingRequest.resolve(message);
|
|
373
387
|
}
|
|
374
388
|
rejectAllPendingRequests(error) {
|
|
375
389
|
for (const [requestId, pendingRequest] of this.pendingRequests.entries()) {
|
|
@@ -378,14 +392,14 @@ class AppServerService {
|
|
|
378
392
|
pendingRequest.reject(new Error(`request ${String(requestId)} failed: ${error.message}`));
|
|
379
393
|
}
|
|
380
394
|
}
|
|
381
|
-
async
|
|
395
|
+
async waitForResponseMessage(requestId, timeoutMs) {
|
|
382
396
|
const immediateResponse = this.pendingResponses.get(requestId);
|
|
383
397
|
if (immediateResponse) {
|
|
384
398
|
this.pendingResponses.delete(requestId);
|
|
385
399
|
if (immediateResponse.error !== undefined) {
|
|
386
400
|
throw new Error(`app-server returned an error for request ${String(requestId)}: ${formatUnknownError(immediateResponse.error)}`);
|
|
387
401
|
}
|
|
388
|
-
return immediateResponse
|
|
402
|
+
return immediateResponse;
|
|
389
403
|
}
|
|
390
404
|
return new Promise((resolve, reject) => {
|
|
391
405
|
const timeout = setTimeout(() => {
|
|
@@ -394,8 +408,8 @@ class AppServerService {
|
|
|
394
408
|
}, timeoutMs);
|
|
395
409
|
const pendingRequest = {
|
|
396
410
|
timeout,
|
|
397
|
-
resolve: (
|
|
398
|
-
resolve(
|
|
411
|
+
resolve: (message) => {
|
|
412
|
+
resolve(message);
|
|
399
413
|
},
|
|
400
414
|
reject: (error) => {
|
|
401
415
|
reject(error);
|
|
@@ -413,7 +427,7 @@ class AppServerService {
|
|
|
413
427
|
reject(new Error(`app-server returned an error for request ${String(requestId)}: ${formatUnknownError(bufferedResponse.error)}`));
|
|
414
428
|
return;
|
|
415
429
|
}
|
|
416
|
-
resolve(bufferedResponse
|
|
430
|
+
resolve(bufferedResponse);
|
|
417
431
|
});
|
|
418
432
|
}
|
|
419
433
|
formatDebugContext() {
|
|
@@ -151,7 +151,7 @@ class AppServerContainerService {
|
|
|
151
151
|
throw error;
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
|
-
this.reportImageStatus(`Docker image '${image}' not found locally.
|
|
154
|
+
this.reportImageStatus(`Docker image '${image}' not found locally. Pulling remotely.`);
|
|
155
155
|
await this.pullImage(image);
|
|
156
156
|
this.reportImageStatus(`Docker image '${image}' is ready.`);
|
|
157
157
|
}
|
|
@@ -210,6 +210,7 @@ class AppServerContainerService {
|
|
|
210
210
|
"-lc",
|
|
211
211
|
bootstrapScript,
|
|
212
212
|
];
|
|
213
|
+
this.reportImageStatus(`Launching Docker container from image '${cfg.runtime_image}'.`);
|
|
213
214
|
const child = (0, node_child_process_1.spawn)("docker", args, {
|
|
214
215
|
stdio: ["pipe", "pipe", "pipe"],
|
|
215
216
|
});
|
|
@@ -230,6 +231,7 @@ class AppServerContainerService {
|
|
|
230
231
|
});
|
|
231
232
|
this.child = child;
|
|
232
233
|
this.running = true;
|
|
234
|
+
this.reportImageStatus(`Waiting for app-server to initialize in Docker container '${this.containerName}'.`);
|
|
233
235
|
}
|
|
234
236
|
async stop() {
|
|
235
237
|
this.running = false;
|
|
@@ -527,7 +527,7 @@ class ThreadContainerService {
|
|
|
527
527
|
throw error;
|
|
528
528
|
}
|
|
529
529
|
}
|
|
530
|
-
imageStatusReporter?.(`Docker image '${image}' not found locally.
|
|
530
|
+
imageStatusReporter?.(`Docker image '${image}' not found locally. Pulling remotely.`);
|
|
531
531
|
await this.pullImage(image, imageStatusReporter);
|
|
532
532
|
imageStatusReporter?.(`Docker image '${image}' is ready.`);
|
|
533
533
|
}
|
|
@@ -539,6 +539,7 @@ class ThreadContainerService {
|
|
|
539
539
|
}
|
|
540
540
|
await this.ensureImageAvailable(options.runtimeImage, options.imageStatusReporter);
|
|
541
541
|
try {
|
|
542
|
+
options.imageStatusReporter?.(`Creating Docker container '${options.names.runtime}' from image '${options.runtimeImage}'.`);
|
|
542
543
|
await this.docker.createContainer(buildRuntimeContainerOptions(options));
|
|
543
544
|
}
|
|
544
545
|
catch (error) {
|
|
@@ -552,8 +553,10 @@ class ThreadContainerService {
|
|
|
552
553
|
if (options.runtimeImage !== options.dindImage) {
|
|
553
554
|
await this.ensureImageAvailable(options.runtimeImage, options.imageStatusReporter);
|
|
554
555
|
}
|
|
556
|
+
options.imageStatusReporter?.(`Creating Docker container '${options.names.dind}' from image '${options.dindImage}'.`);
|
|
555
557
|
await this.docker.createContainer(buildDindContainerOptions(options));
|
|
556
558
|
try {
|
|
559
|
+
options.imageStatusReporter?.(`Creating Docker container '${options.names.runtime}' from image '${options.runtimeImage}'.`);
|
|
557
560
|
await this.docker.createContainer(buildRuntimeContainerOptions(options));
|
|
558
561
|
}
|
|
559
562
|
catch (error) {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RUNNER_DAEMON_STATE_ID = void 0;
|
|
4
|
+
exports.readCurrentDaemonState = readCurrentDaemonState;
|
|
5
|
+
exports.claimCurrentDaemonState = claimCurrentDaemonState;
|
|
6
|
+
exports.clearCurrentDaemonState = clearCurrentDaemonState;
|
|
7
|
+
const drizzle_orm_1 = require("drizzle-orm");
|
|
8
|
+
const db_js_1 = require("./db.js");
|
|
9
|
+
const schema_js_1 = require("./schema.js");
|
|
10
|
+
const process_js_1 = require("../utils/process.js");
|
|
11
|
+
exports.RUNNER_DAEMON_STATE_ID = "runner";
|
|
12
|
+
async function readCurrentDaemonState(stateDbPath) {
|
|
13
|
+
const { db, client } = await (0, db_js_1.initDb)(stateDbPath);
|
|
14
|
+
try {
|
|
15
|
+
const existing = await db.select().from(schema_js_1.daemonState).where((0, drizzle_orm_1.eq)(schema_js_1.daemonState.id, exports.RUNNER_DAEMON_STATE_ID)).all();
|
|
16
|
+
const row = existing[0];
|
|
17
|
+
if (!row) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
id: row.id,
|
|
22
|
+
pid: row.pid ?? null,
|
|
23
|
+
logPath: row.logPath ?? null,
|
|
24
|
+
startedAt: row.startedAt,
|
|
25
|
+
updatedAt: row.updatedAt,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
client.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function claimCurrentDaemonState(stateDbPath, pid, logPath) {
|
|
33
|
+
const now = new Date().toISOString();
|
|
34
|
+
const { client } = await (0, db_js_1.initDb)(stateDbPath);
|
|
35
|
+
try {
|
|
36
|
+
await client.execute("BEGIN IMMEDIATE");
|
|
37
|
+
try {
|
|
38
|
+
const existing = await client.execute({
|
|
39
|
+
sql: "SELECT pid FROM daemon_state WHERE id = ?",
|
|
40
|
+
args: [exports.RUNNER_DAEMON_STATE_ID],
|
|
41
|
+
});
|
|
42
|
+
const row = existing.rows[0];
|
|
43
|
+
const currentPid = typeof row?.pid === "number" ? row.pid : row?.pid == null ? null : Number(row.pid);
|
|
44
|
+
if (currentPid && currentPid !== pid && (0, process_js_1.isProcessRunning)(currentPid)) {
|
|
45
|
+
throw new Error(`Another companyhelm daemon is already running with pid ${currentPid}.`);
|
|
46
|
+
}
|
|
47
|
+
await client.execute({
|
|
48
|
+
sql: "INSERT INTO daemon_state (id, pid, log_path, started_at, updated_at) VALUES (?, ?, ?, ?, ?) " +
|
|
49
|
+
"ON CONFLICT(id) DO UPDATE SET pid = excluded.pid, log_path = excluded.log_path, started_at = excluded.started_at, updated_at = excluded.updated_at",
|
|
50
|
+
args: [exports.RUNNER_DAEMON_STATE_ID, pid, logPath, now, now],
|
|
51
|
+
});
|
|
52
|
+
await client.execute("COMMIT");
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
await client.execute("ROLLBACK");
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
client.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function clearCurrentDaemonState(stateDbPath, pid) {
|
|
64
|
+
const now = new Date().toISOString();
|
|
65
|
+
const { db, client } = await (0, db_js_1.initDb)(stateDbPath);
|
|
66
|
+
try {
|
|
67
|
+
const existing = await db.select().from(schema_js_1.daemonState).where((0, drizzle_orm_1.eq)(schema_js_1.daemonState.id, exports.RUNNER_DAEMON_STATE_ID)).all();
|
|
68
|
+
const current = existing[0];
|
|
69
|
+
if (!current || current.pid !== pid) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
await db
|
|
73
|
+
.update(schema_js_1.daemonState)
|
|
74
|
+
.set({
|
|
75
|
+
pid: null,
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
})
|
|
78
|
+
.where((0, drizzle_orm_1.eq)(schema_js_1.daemonState.id, exports.RUNNER_DAEMON_STATE_ID));
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
client.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/state/schema.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.threadUserMessageRequestStore = exports.threads = exports.llmModels = exports.agentSdks = void 0;
|
|
3
|
+
exports.daemonState = exports.threadUserMessageRequestStore = exports.threads = exports.llmModels = exports.agentSdks = void 0;
|
|
4
4
|
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
|
5
5
|
// ── agent_sdks ──────────────────────────────────────────────────────────────
|
|
6
6
|
exports.agentSdks = (0, sqlite_core_1.sqliteTable)("agent_sdks", {
|
|
@@ -49,3 +49,11 @@ exports.threadUserMessageRequestStore = (0, sqlite_core_1.sqliteTable)("thread_u
|
|
|
49
49
|
requestId: (0, sqlite_core_1.text)("request_id").notNull(),
|
|
50
50
|
sdkItemId: (0, sqlite_core_1.text)("sdk_item_id"),
|
|
51
51
|
});
|
|
52
|
+
// -- daemon_state ------------------------------------------------------------
|
|
53
|
+
exports.daemonState = (0, sqlite_core_1.sqliteTable)("daemon_state", {
|
|
54
|
+
id: (0, sqlite_core_1.text)("id").primaryKey(),
|
|
55
|
+
pid: (0, sqlite_core_1.integer)("pid"),
|
|
56
|
+
logPath: (0, sqlite_core_1.text)("log_path"),
|
|
57
|
+
startedAt: (0, sqlite_core_1.text)("started_at").notNull(),
|
|
58
|
+
updatedAt: (0, sqlite_core_1.text)("updated_at").notNull(),
|
|
59
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
set -euo pipefail
|
|
2
|
+
|
|
3
|
+
AGENT_USER={{ agent_user }}
|
|
4
|
+
AGENT_HOME={{ agent_home }}
|
|
5
|
+
AGENT_UID={{ agent_uid }}
|
|
6
|
+
AGENT_GID={{ agent_gid }}
|
|
7
|
+
CODEX_AUTH_PATH={{ codex_auth_path }}
|
|
8
|
+
APP_SERVER_COMMAND={{ app_server_command }}
|
|
9
|
+
|
|
10
|
+
AGENT_GROUP="$AGENT_USER"
|
|
11
|
+
if getent group "$AGENT_GID" >/dev/null 2>&1; then
|
|
12
|
+
AGENT_GROUP="$(getent group "$AGENT_GID" | cut -d: -f1)"
|
|
13
|
+
elif getent group "$AGENT_USER" >/dev/null 2>&1; then
|
|
14
|
+
groupmod -g "$AGENT_GID" "$AGENT_USER"
|
|
15
|
+
AGENT_GROUP="$AGENT_USER"
|
|
16
|
+
else
|
|
17
|
+
groupadd -g "$AGENT_GID" "$AGENT_USER"
|
|
18
|
+
AGENT_GROUP="$AGENT_USER"
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
if id -u "$AGENT_USER" >/dev/null 2>&1; then
|
|
22
|
+
usermod -u "$AGENT_UID" -g "$AGENT_GROUP" -d "$AGENT_HOME" "$AGENT_USER" || true
|
|
23
|
+
else
|
|
24
|
+
useradd -m -d "$AGENT_HOME" -u "$AGENT_UID" -g "$AGENT_GROUP" -s /bin/bash "$AGENT_USER"
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
SUDOERS_FILE="/etc/sudoers.d/90-companyhelm-agent"
|
|
28
|
+
if command -v sudo >/dev/null 2>&1; then
|
|
29
|
+
install -d -m 0750 /etc/sudoers.d
|
|
30
|
+
printf '%s\n' "$AGENT_USER ALL=(ALL) NOPASSWD:ALL" > "$SUDOERS_FILE"
|
|
31
|
+
chmod 0440 "$SUDOERS_FILE"
|
|
32
|
+
if command -v visudo >/dev/null 2>&1; then
|
|
33
|
+
visudo -cf "$SUDOERS_FILE" >/dev/null
|
|
34
|
+
fi
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
mkdir -p "$AGENT_HOME"
|
|
38
|
+
chown "$AGENT_UID:$AGENT_GID" "$AGENT_HOME" || true
|
|
39
|
+
|
|
40
|
+
if [ -n "${CODEX_AUTH_PATH:-}" ]; then
|
|
41
|
+
mkdir -p "$(dirname "$CODEX_AUTH_PATH")"
|
|
42
|
+
chown -R "$AGENT_UID:$AGENT_GID" "$(dirname "$CODEX_AUTH_PATH")" || true
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
export HOME="$AGENT_HOME"
|
|
46
|
+
exec sudo -n -E -H -u "$AGENT_USER" bash -lc "$APP_SERVER_COMMAND"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Agent Instructions
|
|
2
|
+
|
|
3
|
+
## Workspace Structure
|
|
4
|
+
|
|
5
|
+
- You are running in a thread-specific container and workspace
|
|
6
|
+
- This workspace is not initialized as a Git repository by default. Repositories should live in a subdirectory of the workspace.
|
|
7
|
+
|
|
8
|
+
## Docker
|
|
9
|
+
|
|
10
|
+
Docker is available, the docker host runs in a separate container. The network is shared with container.
|
|
11
|
+
- only the `/workspace` is shared with the docker host
|
|
12
|
+
- `{{home_directory}}/.codex/auth.json` is also shared with the docker host
|
|
13
|
+
- Nested DinD is not supported in this environment because the outer runtime already uses rootless DinD.
|
|
14
|
+
- If you need to use Docker in Docker you can mount the docker socket in the container and use this environment DinD to run containers within containers.
|
|
15
|
+
|
|
16
|
+
## GitHub Installations
|
|
17
|
+
|
|
18
|
+
- Synced GitHub installation credentials are written to `/workspace/.companyhelm/installations.json`.
|
|
19
|
+
- Use `list-installations` to inspect installation IDs, repository scopes, tokens, and expiration timestamps.
|
|
20
|
+
- Use `gh-use-installation <installation-id>` to configure `gh` authentication for a specific installation.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Inspect synced installation credentials
|
|
24
|
+
list-installations
|
|
25
|
+
|
|
26
|
+
# Configure gh to use installation 112331765
|
|
27
|
+
gh-use-installation 112331765
|
|
28
|
+
|
|
29
|
+
# Verify gh is authenticated for github.com
|
|
30
|
+
gh auth status --hostname github.com
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Available CLI Tools
|
|
34
|
+
|
|
35
|
+
- `list-installations`: list synced GitHub installations with repositories, access tokens, and expirations.
|
|
36
|
+
- `gh-use-installation <installation-id>`: configure `gh` authentication for a selected GitHub installation token.
|
|
37
|
+
- `aws`: AWS CLI is pre-installed and available in `PATH`.
|
|
38
|
+
- For scripted PR creation/updates, always use `gh pr create --body-file <path>` and `gh pr edit --body-file <path>` instead of inline `--body` to avoid shell interpolation of markdown backticks.
|
|
39
|
+
- DO NOT INSTALL PLAYWRIGHT IN THE RUNTIME IMAGE. Playwright CLI is already installed and available for browser automation tasks with Chromium pre-installed: `playwright open --browser=chromium ...`
|
|
40
|
+
|
|
41
|
+
## CompanyHelm Agent CLI
|
|
42
|
+
|
|
43
|
+
- `companyhelm-agent` is pre-installed in the runtime image.
|
|
44
|
+
- Thread bootstrap writes `{{home_directory}}/.config/companyhelm-agent-cli/config.json` with:
|
|
45
|
+
- `agent_api_url`: localhost targets are rewritten to `host.docker.internal` (for example `http://host.docker.internal:<port>`) for Docker-to-host access.
|
|
46
|
+
- `token`: sourced from the thread secret.
|
|
47
|
+
- Example commands:
|
|
48
|
+
- `companyhelm-agent task get --task-id <id>`
|
|
49
|
+
- `companyhelm-agent task dependencies --task-id <id>`
|
|
50
|
+
- `companyhelm-agent task update-status --task-id <id> --status <draft|pending|in_progress|completed>`
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# CompanyHelm runtime shell initialization.
|
|
2
|
+
# This file is generated by companyhelm-cli when a thread runtime starts.
|
|
3
|
+
|
|
4
|
+
if [ -z "${NVM_DIR:-}" ] || [ ! -s "$NVM_DIR/nvm.sh" ]; then
|
|
5
|
+
for candidate in "/usr/local/nvm" "$HOME/.nvm" "/opt/nvm" "/root/.nvm"; do
|
|
6
|
+
if [ -s "$candidate/nvm.sh" ]; then
|
|
7
|
+
export NVM_DIR="$candidate"
|
|
8
|
+
break
|
|
9
|
+
fi
|
|
10
|
+
done
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
if [ -n "${NVM_DIR:-}" ] && [ -s "$NVM_DIR/nvm.sh" ]; then
|
|
14
|
+
. "$NVM_DIR/nvm.sh"
|
|
15
|
+
nvm use --silent default >/dev/null 2>&1 || true
|
|
16
|
+
if [ -s "$NVM_DIR/bash_completion" ]; then
|
|
17
|
+
. "$NVM_DIR/bash_completion"
|
|
18
|
+
fi
|
|
19
|
+
fi
|