diff --git a/container/build.sh b/container/build.sh index fd5210d09..ae0c3d913 100755 --- a/container/build.sh +++ b/container/build.sh @@ -9,9 +9,15 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$SCRIPT_DIR" -IMAGE_NAME="nanoclaw-agent" +# Derive the image name from the project root so two NanoClaw installs on the +# same host don't overwrite each other's `nanoclaw-agent:latest` tag. Matches +# setup/lib/install-slug.sh + src/install-slug.ts. +# shellcheck source=../setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" +IMAGE_NAME="$(container_image_base)" TAG="${1:-latest}" CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" diff --git a/package.json b/package.json index 8ec29839d..1d67485dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.2", + "version": "2.0.4", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 20db68f7c..3fc904ec7 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 127k tokens, 64% of context window + + 128k tokens, 64% of context window @@ -15,8 +15,8 @@ tokens - - 127k + + 128k diff --git a/setup/add-discord.sh b/setup/add-discord.sh index 74ce9a783..fa614fdb6 100755 --- a/setup/add-discord.sh +++ b/setup/add-discord.sh @@ -110,13 +110,15 @@ mkdir -p data/env cp .env data/env/env log "Restarting service so the new adapter picks up the credentials…" +# shellcheck source=setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" case "$(uname -s)" in Darwin) - launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true + launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true ;; Linux) - systemctl --user restart nanoclaw >&2 2>/dev/null \ - || sudo systemctl restart nanoclaw >&2 2>/dev/null \ + systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \ + || sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \ || true ;; esac diff --git a/setup/add-teams.sh b/setup/add-teams.sh index 99ceb4aff..273cad6d8 100755 --- a/setup/add-teams.sh +++ b/setup/add-teams.sh @@ -119,13 +119,15 @@ mkdir -p data/env cp .env data/env/env log "Restarting service so the new adapter picks up the credentials…" +# shellcheck source=setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" case "$(uname -s)" in Darwin) - launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true + launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true ;; Linux) - systemctl --user restart nanoclaw >&2 2>/dev/null \ - || sudo systemctl restart nanoclaw >&2 2>/dev/null \ + systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \ + || sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \ || true ;; esac diff --git a/setup/add-telegram.sh b/setup/add-telegram.sh index 0d7fd5c3f..c81fc6def 100755 --- a/setup/add-telegram.sh +++ b/setup/add-telegram.sh @@ -144,13 +144,15 @@ cp .env data/env/env # non-interactive install. log "Restarting service so the new adapter picks up the token…" +# shellcheck source=setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" case "$(uname -s)" in Darwin) - launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true + launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true ;; Linux) - systemctl --user restart nanoclaw >&2 2>/dev/null \ - || sudo systemctl restart nanoclaw >&2 2>/dev/null \ + systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \ + || sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \ || true ;; esac diff --git a/setup/auto.ts b/setup/auto.ts index 958650aa1..b5b01131e 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -34,6 +34,7 @@ import { pingCliAgent, type PingResult } from './lib/agent-ping.js'; import { brightSelect } from './lib/bright-select.js'; import { offerClaudeAssist } from './lib/claude-assist.js'; import { runWindowedStep } from './lib/windowed-runner.js'; +import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { claudeCliAvailable, resolveTimezoneViaClaude, @@ -308,13 +309,14 @@ async function main(): Promise { } const service = res.terminal?.fields.SERVICE; if (service === 'running_other_checkout') { + const label = getLaunchdLabel(); notes.push( wrapForGutter( [ '• Your NanoClaw service is running from a different folder on this machine.', ' Point it at this checkout with:', - ' launchctl bootout gui/$(id -u)/com.nanoclaw', - ' launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.plist', + ` launchctl bootout gui/$(id -u)/${label}`, + ` launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/${label}.plist`, ].join('\n'), 6, ), @@ -460,8 +462,8 @@ function renderPingFailureNote(result: PingResult): void { 6, ), '', - k.dim(' macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw'), - k.dim(' Linux: systemctl --user restart nanoclaw'), + k.dim(` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`), + k.dim(` Linux: systemctl --user restart ${getSystemdUnit()}`), ].join('\n') : wrapForGutter( 'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.', diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index f24207a33..85c98663a 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -34,6 +34,7 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { brightSelect } from '../lib/bright-select.js'; +import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; import { type Block, type StepResult, @@ -359,17 +360,18 @@ async function restartService(): Promise { if (platform === 'darwin') { spawnSync( 'launchctl', - ['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/com.nanoclaw`], + ['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`], { stdio: 'ignore' }, ); } else if (platform === 'linux') { + const unit = getSystemdUnit(); const user = spawnSync( 'systemctl', - ['--user', 'restart', 'nanoclaw'], + ['--user', 'restart', unit], { stdio: 'ignore' }, ); if (user.status !== 0) { - spawnSync('sudo', ['systemctl', 'restart', 'nanoclaw'], { + spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore', }); } diff --git a/setup/container.ts b/setup/container.ts index a15ddb470..6ecd03200 100644 --- a/setup/container.ts +++ b/setup/container.ts @@ -7,6 +7,7 @@ import path from 'path'; import { setTimeout as sleep } from 'timers/promises'; import { log } from '../src/log.js'; +import { getDefaultContainerImage } from '../src/install-slug.js'; import { commandExists, getPlatform } from './platform.js'; import { emitStatus } from './status.js'; @@ -81,7 +82,7 @@ function parseArgs(args: string[]): { runtime: string } { export async function run(args: string[]): Promise { const projectRoot = process.cwd(); const { runtime } = parseArgs(args); - const image = 'nanoclaw-agent:latest'; + const image = getDefaultContainerImage(projectRoot); const logFile = path.join(projectRoot, 'logs', 'setup.log'); if (runtime !== 'docker') { diff --git a/setup/lib/install-slug.sh b/setup/lib/install-slug.sh new file mode 100644 index 000000000..736d339a7 --- /dev/null +++ b/setup/lib/install-slug.sh @@ -0,0 +1,37 @@ +# install-slug.sh — shell mirror of setup/lib/install-slug.ts. +# +# Source this file after $PROJECT_ROOT is set: +# +# source "$PROJECT_ROOT/setup/lib/install-slug.sh" +# label=$(launchd_label) # com.nanoclaw-v2- +# unit=$(systemd_unit) # nanoclaw-v2- +# image=$(container_image_base) # nanoclaw-agent-v2- +# +# Slug is sha1(PROJECT_ROOT)[:8] — must match the TS helper exactly so both +# halves of setup name things consistently. + +_nanoclaw_install_slug() { + local root="${NANOCLAW_PROJECT_ROOT:-${PROJECT_ROOT:-$PWD}}" + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$root" | shasum | cut -c 1-8 + elif command -v sha1sum >/dev/null 2>&1; then + printf '%s' "$root" | sha1sum | cut -c 1-8 + else + # Fallback: hash the path with something deterministic-ish. Not ideal — + # but shasum is present on every modern macOS/Linux, so this is just + # belt-and-braces against a truly minimal system. + printf '%s' "$root" | od -An -tx1 | tr -d ' \n' | cut -c 1-8 + fi +} + +launchd_label() { + printf 'com.nanoclaw-v2-%s' "$(_nanoclaw_install_slug)" +} + +systemd_unit() { + printf 'nanoclaw-v2-%s' "$(_nanoclaw_install_slug)" +} + +container_image_base() { + printf 'nanoclaw-agent-v2-%s' "$(_nanoclaw_install_slug)" +} diff --git a/setup/probe.sh b/setup/probe.sh index 6f40fff55..f4cbf3faa 100755 --- a/setup/probe.sh +++ b/setup/probe.sh @@ -19,7 +19,13 @@ START_S=$(date +%s) PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" LOCAL_BIN="$HOME/.local/bin" -AGENT_IMAGE="nanoclaw-agent:latest" + +# Per-checkout install names (match setup/lib/install-slug.ts). +# shellcheck source=setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" +LAUNCHD_LABEL=$(launchd_label) +SYSTEMD_UNIT=$(systemd_unit) +AGENT_IMAGE="$(container_image_base):latest" export PATH="$LOCAL_BIN:$PATH" @@ -144,7 +150,7 @@ probe_service_status() { macos) command_exists launchctl || { echo "not_configured"; return; } local line - line=$(with_timeout launchctl list 2>/dev/null | grep "com.nanoclaw") || { + line=$(with_timeout launchctl list 2>/dev/null | grep "$LAUNCHD_LABEL") || { echo "not_configured"; return; } local pid pid=$(echo "$line" | awk '{print $1}') @@ -156,7 +162,7 @@ probe_service_status() { ;; linux|wsl) command_exists systemctl || { echo "not_configured"; return; } - if with_timeout systemctl --user is-active nanoclaw >/dev/null 2>&1; then + if with_timeout systemctl --user is-active "$SYSTEMD_UNIT" >/dev/null 2>&1; then echo "running" elif with_timeout systemctl --user cat nanoclaw >/dev/null 2>&1; then echo "stopped" diff --git a/setup/service.test.ts b/setup/service.test.ts index 9168fe109..9bc899ebf 100644 --- a/setup/service.test.ts +++ b/setup/service.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest'; import path from 'path'; +import { getLaunchdLabel } from '../src/install-slug.js'; + /** * Tests for service configuration generation. * @@ -14,12 +16,13 @@ function generatePlist( projectRoot: string, homeDir: string, ): string { + const label = getLaunchdLabel(projectRoot); return ` Label - com.nanoclaw + ${label} ProgramArguments ${nodePath} @@ -73,13 +76,11 @@ WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`; } describe('plist generation', () => { - it('contains the correct label', () => { - const plist = generatePlist( - '/usr/local/bin/node', - '/home/user/nanoclaw', - '/home/user', - ); - expect(plist).toContain('com.nanoclaw'); + it('contains the slug-scoped label', () => { + const projectRoot = '/home/user/nanoclaw'; + const plist = generatePlist('/usr/local/bin/node', projectRoot, '/home/user'); + expect(plist).toContain(`${getLaunchdLabel(projectRoot)}`); + expect(plist).toMatch(/com\.nanoclaw-v2-[0-9a-f]{8}<\/string>/); }); it('uses the correct node path', () => { diff --git a/setup/service.ts b/setup/service.ts index f5ad85562..79304610f 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -10,6 +10,7 @@ import os from 'os'; import path from 'path'; import { log } from '../src/log.js'; +import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { commandExists, getPlatform, @@ -74,11 +75,14 @@ function setupLaunchd( nodePath: string, homeDir: string, ): void { + // Per-checkout service label so multiple NanoClaw installs can coexist + // without clobbering each other's plist. + const label = getLaunchdLabel(projectRoot); const plistPath = path.join( homeDir, 'Library', 'LaunchAgents', - 'com.nanoclaw.plist', + `${label}.plist`, ); fs.mkdirSync(path.dirname(plistPath), { recursive: true }); @@ -87,7 +91,7 @@ function setupLaunchd( Label - com.nanoclaw + ${label} ProgramArguments ${nodePath} @@ -146,13 +150,14 @@ function setupLaunchd( let serviceLoaded = false; try { const output = execSync('launchctl list', { encoding: 'utf-8' }); - serviceLoaded = output.includes('com.nanoclaw'); + serviceLoaded = output.includes(label); } catch { // launchctl list failed } emitStatus('SETUP_SERVICE', { SERVICE_TYPE: 'launchd', + SERVICE_LABEL: label, NODE_PATH: nodePath, PROJECT_PATH: projectRoot, PLIST_PATH: plistPath, @@ -225,13 +230,15 @@ function setupSystemd( homeDir: string, ): void { const runningAsRoot = isRoot(); + const unitName = getSystemdUnit(projectRoot); + const unitFileName = `${unitName}.service`; // Root uses system-level service, non-root uses user-level let unitPath: string; let systemctlPrefix: string; if (runningAsRoot) { - unitPath = '/etc/systemd/system/nanoclaw.service'; + unitPath = `/etc/systemd/system/${unitFileName}`; systemctlPrefix = 'systemctl'; log.info('Running as root — installing system-level systemd unit'); } else { @@ -247,7 +254,7 @@ function setupSystemd( } const unitDir = path.join(homeDir, '.config', 'systemd', 'user'); fs.mkdirSync(unitDir, { recursive: true }); - unitPath = path.join(unitDir, 'nanoclaw.service'); + unitPath = path.join(unitDir, unitFileName); systemctlPrefix = 'systemctl --user'; } @@ -328,7 +335,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; } try { - execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' }); + execSync(`${systemctlPrefix} enable ${unitName}`, { stdio: 'ignore' }); } catch (err) { log.error('systemctl enable failed', { err }); } @@ -339,7 +346,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; // `restart` on a stopped unit is equivalent to `start`, so this is safe // as a first-install path too. try { - execSync(`${systemctlPrefix} restart nanoclaw`, { stdio: 'ignore' }); + execSync(`${systemctlPrefix} restart ${unitName}`, { stdio: 'ignore' }); } catch (err) { log.error('systemctl restart failed', { err }); } @@ -347,7 +354,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; // Verify let serviceLoaded = false; try { - execSync(`${systemctlPrefix} is-active nanoclaw`, { stdio: 'ignore' }); + execSync(`${systemctlPrefix} is-active ${unitName}`, { stdio: 'ignore' }); serviceLoaded = true; } catch { // Not active @@ -355,6 +362,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; emitStatus('SETUP_SERVICE', { SERVICE_TYPE: runningAsRoot ? 'systemd-system' : 'systemd-user', + SERVICE_UNIT: unitName, NODE_PATH: nodePath, PROJECT_PATH: projectRoot, UNIT_PATH: unitPath, diff --git a/setup/verify.ts b/setup/verify.ts index ab0b80e0f..281b243ce 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -15,6 +15,7 @@ import { DATA_DIR } from '../src/config.js'; import { readEnvFile } from '../src/env.js'; import { log } from '../src/log.js'; import { pingCliAgent } from './lib/agent-ping.js'; +import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; import { getPlatform, getServiceManager, @@ -45,10 +46,13 @@ export async function run(_args: string[]): Promise { let runningFromPath: string | null = null; const mgr = getServiceManager(); + const launchdLabel = getLaunchdLabel(projectRoot); + const systemdUnit = getSystemdUnit(projectRoot); + if (mgr === 'launchd') { try { const output = execSync('launchctl list', { encoding: 'utf-8' }); - const line = output.split('\n').find((l) => l.includes('com.nanoclaw')); + const line = output.split('\n').find((l) => l.includes(launchdLabel)); if (line) { const pidField = line.trim().split(/\s+/)[0]; if (pidField !== '-' && pidField) { @@ -67,11 +71,11 @@ export async function run(_args: string[]): Promise { } else if (mgr === 'systemd') { const prefix = isRoot() ? 'systemctl' : 'systemctl --user'; try { - execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' }); + execSync(`${prefix} is-active ${systemdUnit}`, { stdio: 'ignore' }); service = 'running'; try { const pidStr = execSync( - `${prefix} show nanoclaw -p MainPID --value`, + `${prefix} show ${systemdUnit} -p MainPID --value`, { encoding: 'utf-8' }, ).trim(); const pid = Number(pidStr); @@ -86,7 +90,7 @@ export async function run(_args: string[]): Promise { const output = execSync(`${prefix} list-unit-files`, { encoding: 'utf-8', }); - if (output.includes('nanoclaw')) { + if (output.includes(systemdUnit)) { service = 'stopped'; } } catch { diff --git a/src/config.ts b/src/config.ts index 96b782a17..79a1ce9df 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ import os from 'os'; import path from 'path'; import { readEnvFile } from './env.js'; +import { getContainerImageBase, getDefaultContainerImage } from './install-slug.js'; import { isValidTimezone } from './timezone.js'; // Read config values from .env (falls back to process.env). @@ -22,7 +23,10 @@ export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); -export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; +// Per-checkout image tag so two installs on the same host don't share +// `nanoclaw-agent:latest` and clobber each other on rebuild. +export const CONTAINER_IMAGE_BASE = process.env.CONTAINER_IMAGE_BASE || getContainerImageBase(PROJECT_ROOT); +export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || getDefaultContainerImage(PROJECT_ROOT); export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL; diff --git a/src/container-runner.ts b/src/container-runner.ts index 8291d4245..646b11811 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -9,7 +9,15 @@ import path from 'path'; import { OneCLI } from '@onecli-sh/sdk'; -import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, ONECLI_API_KEY, ONECLI_URL, TIMEZONE } from './config.js'; +import { + CONTAINER_IMAGE, + CONTAINER_IMAGE_BASE, + DATA_DIR, + GROUPS_DIR, + ONECLI_API_KEY, + ONECLI_URL, + TIMEZONE, +} from './config.js'; import { readContainerConfig, writeContainerConfig } from './container-config.js'; import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { composeGroupClaudeMd } from './claude-md-compose.js'; @@ -469,7 +477,7 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise } dockerfile += 'USER node\n'; - const imageTag = `nanoclaw-agent:${agentGroupId}`; + const imageTag = `${CONTAINER_IMAGE_BASE}:${agentGroupId}`; log.info('Building per-agent-group image', { agentGroupId, imageTag, apt: aptPackages, npm: npmPackages }); diff --git a/src/install-slug.ts b/src/install-slug.ts new file mode 100644 index 000000000..8d6443a3e --- /dev/null +++ b/src/install-slug.ts @@ -0,0 +1,33 @@ +/** + * Per-checkout install identifiers. Lets two NanoClaw installs coexist on + * one host without clobbering each other's service registration or the + * shared `nanoclaw-agent:latest` docker image tag. + * + * Slug is sha1(projectRoot)[:8] — deterministic per checkout path, stable + * across re-runs, unique enough across installs. + */ +import { createHash } from 'crypto'; + +export function getInstallSlug(projectRoot: string = process.cwd()): string { + return createHash('sha1').update(projectRoot).digest('hex').slice(0, 8); +} + +/** launchd Label + plist basename. e.g. `com.nanoclaw-v2-ab12cd34`. */ +export function getLaunchdLabel(projectRoot?: string): string { + return `com.nanoclaw-v2-${getInstallSlug(projectRoot)}`; +} + +/** systemd unit name (no .service suffix). e.g. `nanoclaw-v2-ab12cd34`. */ +export function getSystemdUnit(projectRoot?: string): string { + return `nanoclaw-v2-${getInstallSlug(projectRoot)}`; +} + +/** Docker image base (no tag). e.g. `nanoclaw-agent-v2-ab12cd34`. */ +export function getContainerImageBase(projectRoot?: string): string { + return `nanoclaw-agent-v2-${getInstallSlug(projectRoot)}`; +} + +/** Default full container image reference with `:latest` tag. */ +export function getDefaultContainerImage(projectRoot?: string): string { + return `${getContainerImageBase(projectRoot)}:latest`; +}