Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 19 additions & 16 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ COPY scripts/postinstall.sh scripts/
RUN bun install --frozen-lockfile && \
touch node_modules/.installed

# Archive all externalized packages and their transitive deps (including installed
# optional deps such as platform-specific native binaries) into a single tarball.
# The runtime stage extracts this in one step; the script auto-derives the full
# closure so the Dockerfile never needs updating when transitive deps change.
COPY scripts/collect-runtime-deps.js scripts/
RUN node scripts/collect-runtime-deps.js /tmp/runtime-deps.tar.gz \
@lydell/node-pty node-pty \
ssh2 \
sharp \
@1password/sdk @1password/sdk-core \
jsdom

# Copy build orchestration files used by Make targets.
COPY Makefile fmt.mk ./

Expand Down Expand Up @@ -89,22 +101,13 @@ RUN apt-get update && \
apt-get install -y --no-install-recommends git openssh-client ca-certificates && \
rm -rf /var/lib/apt/lists/*

# Copy runtime dependencies first so app-code changes don't invalidate these layers.
# - @lydell/node-pty: native module for terminal support
# - ssh2 + deps: externalized to avoid .node addon bundling issues
COPY --from=builder /app/node_modules/@lydell ./node_modules/@lydell
COPY --from=builder /app/node_modules/ssh2 ./node_modules/ssh2
COPY --from=builder /app/node_modules/asn1 ./node_modules/asn1
COPY --from=builder /app/node_modules/safer-buffer ./node_modules/safer-buffer
COPY --from=builder /app/node_modules/bcrypt-pbkdf ./node_modules/bcrypt-pbkdf
COPY --from=builder /app/node_modules/tweetnacl ./node_modules/tweetnacl
# - sharp + runtime deps: externalized for attach_file raster resizing in bundled server mode
COPY --from=builder /app/node_modules/sharp ./node_modules/sharp
COPY --from=builder /app/node_modules/@img ./node_modules/@img
COPY --from=builder /app/node_modules/detect-libc ./node_modules/detect-libc
COPY --from=builder /app/node_modules/semver ./node_modules/semver
# - @1password/sdk + sdk-core: externalized; contains native WASM for secret resolution
COPY --from=builder /app/node_modules/@1password ./node_modules/@1password
# Extract all externalized runtime packages (node-pty, ssh2, sharp, @1password, jsdom)
# and their full transitive dependency closures in one step. The tarball is built by
# scripts/collect-runtime-deps.js in the builder stage and self-maintains as deps change.
COPY --from=builder /tmp/runtime-deps.tar.gz /tmp/
RUN mkdir -p node_modules && \
tar xzf /tmp/runtime-deps.tar.gz -C node_modules && \
rm /tmp/runtime-deps.tar.gz

# Copy frontend/static assets from least to most volatile for better cache reuse.
# Vite outputs JS/CSS/HTML directly to dist/ (assetsDir: ".").
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ ESBUILD_CLI_FLAGS := --bundle --format=esm --platform=node --target=node20 --out
# Common esbuild flags for server runtime Docker bundle.
# Place runtime bundles under dist/runtime so frontend dist/*.js layers remain stable.
# External native modules (node-pty, ssh2) and electron remain runtime dependencies.
ESBUILD_SERVER_FLAGS := --bundle --platform=node --target=node22 --format=cjs --outfile=dist/runtime/server-bundle.js --external:@lydell/node-pty --external:node-pty --external:electron --external:ssh2 --external:@1password/sdk --external:@1password/sdk-core --alias:jsonc-parser=jsonc-parser/lib/esm/main.js --minify
ESBUILD_SERVER_FLAGS := --bundle --platform=node --target=node22 --format=cjs --outfile=dist/runtime/server-bundle.js --external:@lydell/node-pty --external:node-pty --external:electron --external:ssh2 --external:@1password/sdk --external:@1password/sdk-core --external:jsdom --alias:jsonc-parser=jsonc-parser/lib/esm/main.js --minify

# Common esbuild flags for tokenizer worker bundle used by server-bundle runtime.
ESBUILD_TOKENIZER_WORKER_FLAGS := --bundle --platform=node --target=node22 --format=cjs --outfile=dist/runtime/tokenizer.worker.js --minify
Expand Down
60 changes: 60 additions & 0 deletions scripts/collect-runtime-deps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env node
// Collect externalized npm packages and their transitive dependencies into a
// single tarball for use in the Docker runtime stage.
//
// Usage: node scripts/collect-runtime-deps.js <out.tar.gz> <pkg1> [pkg2 ...]
//
// Walks both `dependencies` and `optionalDependencies`. Optional packages that
// are not installed (e.g. wrong-platform sharp binaries) are skipped silently.
// Produces a tarball whose entries are relative to node_modules/, so it can be
// extracted directly into any node_modules/ directory.
"use strict";

const { spawnSync } = require("child_process");
const fs = require("fs");
const path = require("path");

const nodeModules = path.resolve(__dirname, "..", "node_modules");
const [, , outFile, ...roots] = process.argv;

if (!outFile || roots.length === 0) {
process.stderr.write(
"Usage: collect-runtime-deps.js <out.tar.gz> <pkg1> [pkg2 ...]\n"
);
process.exit(1);
}

const collected = new Set();

function collect(pkgName) {
if (collected.has(pkgName)) return;
if (!fs.existsSync(path.join(nodeModules, pkgName))) return; // optional dep not installed
collected.add(pkgName);

let pkg;
try {
pkg = JSON.parse(
fs.readFileSync(path.join(nodeModules, pkgName, "package.json"), "utf8")
);
} catch {
return;
}

for (const dep of Object.keys(pkg.dependencies ?? {})) collect(dep);
for (const dep of Object.keys(pkg.optionalDependencies ?? {})) collect(dep);
}

for (const root of roots) {
collect(root);
}

const packages = [...collected].sort();
process.stdout.write(
`Archiving ${packages.length} packages → ${outFile}\n`
);

const result = spawnSync("tar", ["czf", outFile, ...packages], {
cwd: nodeModules,
stdio: "inherit",
});
process.exit(result.status ?? 1);
5 changes: 1 addition & 4 deletions src/common/utils/tools/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { createMuxConfigReadTool } from "@/node/services/tools/mux_config_read";
import { createMuxConfigWriteTool } from "@/node/services/tools/mux_config_write";
import { createAgentReportTool } from "@/node/services/tools/agent_report";
import { createSwitchAgentTool } from "@/node/services/tools/switch_agent";
import { createWebFetchTool } from "@/node/services/tools/web_fetch";
import { wrapWithInitWait } from "@/node/services/tools/wrapWithInitWait";
import { withHooks, type HookConfig } from "@/node/services/tools/withHooks";
import { log } from "@/node/services/log";
Expand Down Expand Up @@ -411,10 +412,6 @@ export async function getToolsForModel(
const wrap = <TParameters, TResult>(tool: Tool<TParameters, TResult>) =>
wrapWithInitWait(tool, workspaceId, initStateManager);

// Lazy-load web_fetch to avoid loading jsdom (ESM-only) at Jest setup time
// This allows integration tests to run without transforming jsdom's dependencies
const { createWebFetchTool } = await import("@/node/services/tools/web_fetch");

// Runtime-dependent tools need to wait for workspace initialization
// Wrap them to handle init waiting centrally instead of in each tool
const runtimeTools: Record<string, Tool> = {
Expand Down
12 changes: 12 additions & 0 deletions src/node/services/workspaceTitleGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,21 @@ export async function generateWorkspaceIdentity(
// which the StreamManager enforces via stopWhen for full agent sessions.
// For this direct streamText path, the candidate retry loop handles the
// (rare) case where the model ignores the instruction.
// 15 s deadline per candidate — prevents indefinite hangs when a custom
// OpenAI-compatible provider stalls or doesn't support tool calls and
// never closes the stream.
//
// toolChoice "required": name generation is a structured extraction task,
// not an open-ended conversation. Without it, small or thinking-mode models
// (e.g. Qwen3 via LiteLLM) often reply in plain text and never call the
// tool. The original omission was to preserve compatibility with extended-
// thinking models in the main chat stream, but this call is separate and
// does not use thinking — forcing the tool call is safe here.
const currentStream = streamText({
model: modelResult.data,
prompt: buildWorkspaceIdentityPrompt(message, conversationContext, latestUserMessage),
abortSignal: AbortSignal.timeout(15_000),
toolChoice: "required",
tools: {
// Defined inline so TypeScript preserves full schema inference on
// toolResult.output (the propose_name tool is only used here).
Expand Down