Merge remote-tracking branch 'origin/main' into setup-feedback-fixes

# Conflicts:
#	setup/auto.ts
#	setup/channels/whatsapp.ts
This commit is contained in:
gavrielc
2026-04-23 10:39:35 +03:00
17 changed files with 167 additions and 49 deletions

View File

@@ -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}"

View File

@@ -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",

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="127k tokens, 64% of context window">
<title>127k tokens, 64% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="128k tokens, 64% of context window">
<title>128k tokens, 64% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">127k</text>
<text x="71" y="14">127k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">128k</text>
<text x="71" y="14">128k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<void> {
}
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`.',

View File

@@ -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<void> {
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',
});
}

View File

@@ -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<void> {
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') {

37
setup/lib/install-slug.sh Normal file
View File

@@ -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-<slug>
# unit=$(systemd_unit) # nanoclaw-v2-<slug>
# image=$(container_image_base) # nanoclaw-agent-v2-<slug>
#
# 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)"
}

View File

@@ -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"

View File

@@ -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 `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<string>${label}</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
@@ -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('<string>com.nanoclaw</string>');
it('contains the slug-scoped label', () => {
const projectRoot = '/home/user/nanoclaw';
const plist = generatePlist('/usr/local/bin/node', projectRoot, '/home/user');
expect(plist).toContain(`<string>${getLaunchdLabel(projectRoot)}</string>`);
expect(plist).toMatch(/<string>com\.nanoclaw-v2-[0-9a-f]{8}<\/string>/);
});
it('uses the correct node path', () => {

View File

@@ -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(
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw</string>
<string>${label}</string>
<key>ProgramArguments</key>
<array>
<string>${nodePath}</string>
@@ -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,

View File

@@ -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<void> {
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<void> {
} 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<void> {
const output = execSync(`${prefix} list-unit-files`, {
encoding: 'utf-8',
});
if (output.includes('nanoclaw')) {
if (output.includes(systemdUnit)) {
service = 'stopped';
}
} catch {

View File

@@ -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;

View File

@@ -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<void>
}
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 });

33
src/install-slug.ts Normal file
View File

@@ -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`;
}