feat(setup): ping agent before chat, detect stale service, auto-install Claude
Round-trip confirmation before first chat. After cli-agent wires up the Terminal Agent, send `chat ping` through the CLI socket under a spinner with 30s timeout (shared helper in setup/lib/agent-ping.ts, also used by verify). Only after a real reply do we show "Your assistant is ready." and enter the chat loop. Ping failures surface a targeted note (socket_error vs no_reply) and skip the prompt — so users never type into the void. Checkout-mismatch detection. verify resolves the running service PID's script path via `ps -p <pid> -o command=` and compares to projectRoot. If the service is running from a sibling clone (common for developers with multiple checkouts), SERVICE comes back as running_other_checkout instead of running, AGENT_PING is skipped, and the failure note tells the user exactly which bootout + bootstrap pair to run. Native Claude Code install on demand. Only the subscription auth path needs `claude`; the paste-token and paste-API-key paths don't. So register-claude-token.sh now runs setup/install-claude.sh when `claude` is missing (curl -fsSL https://claude.ai/install.sh | bash), then prepends ~/.local/bin to PATH in-process so the rest of the script can see the fresh binary. Gutter-safe wrapping. wrapForGutter + dimWrap in lib/theme.ts hard-wrap text to `process.stdout.columns - gutter` on word boundaries, measuring visible length (ANSI-stripped). dimWrap applies the dim envelope per line because clack resets styling at each line break when rendering multi-line log content — a single outer dim() only colors the first line. Applied to the long "why" notes before container + onecli, the channel-skip info, the ping-failure note, and the checkout-mismatch remediation. Wordmark anchoring. printIntro always includes the NanoClaw wordmark in the clack intro line, whether or not nanoclaw.sh already printed one in bash. Worth ~1 line of redundancy so the brand stays visible at the top of the clack session after bootstrap output scrolls out. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
152
setup/auto.ts
152
setup/auto.ts
@@ -14,7 +14,7 @@
|
||||
* "Terminal Agent".
|
||||
* NANOCLAW_SKIP comma-separated step names to skip
|
||||
* (environment|container|onecli|auth|mounts|
|
||||
* service|cli-agent|channel|verify)
|
||||
* service|cli-agent|channel|verify|first-chat)
|
||||
*
|
||||
* Timezone defaults to the host system's TZ. Run
|
||||
* pnpm exec tsx setup/index.ts --step timezone -- --tz <zone>
|
||||
@@ -27,9 +27,10 @@ import k from 'kleur';
|
||||
|
||||
import { runDiscordChannel } from './channels/discord.js';
|
||||
import { runTelegramChannel } from './channels/telegram.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import * as setupLog from './logs.js';
|
||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
||||
import { brandBold, brandChip } from './lib/theme.js';
|
||||
import { brandBold, brandChip, dimWrap, wrapForGutter } from './lib/theme.js';
|
||||
|
||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||
const RUN_START = Date.now();
|
||||
@@ -61,8 +62,9 @@ async function main(): Promise<void> {
|
||||
|
||||
if (!skip.has('container')) {
|
||||
p.log.message(
|
||||
k.dim(
|
||||
dimWrap(
|
||||
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
|
||||
4,
|
||||
),
|
||||
);
|
||||
const res = await runQuietStep('container', {
|
||||
@@ -97,8 +99,9 @@ async function main(): Promise<void> {
|
||||
|
||||
if (!skip.has('onecli')) {
|
||||
p.log.message(
|
||||
k.dim(
|
||||
dimWrap(
|
||||
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
|
||||
4,
|
||||
),
|
||||
);
|
||||
const res = await runQuietStep('onecli', {
|
||||
@@ -178,18 +181,26 @@ async function main(): Promise<void> {
|
||||
const res = await runQuietStep(
|
||||
'cli-agent',
|
||||
{
|
||||
running: 'Setting up your terminal chat…',
|
||||
done: 'Terminal chat ready. Try `pnpm run chat hi`.',
|
||||
running: 'Bringing your assistant online…',
|
||||
done: 'Assistant wired up.',
|
||||
},
|
||||
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
|
||||
);
|
||||
if (!res.ok) {
|
||||
fail(
|
||||
'cli-agent',
|
||||
"Couldn't set up the terminal chat.",
|
||||
"Couldn't bring your assistant online.",
|
||||
`You can retry later with \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\`.`,
|
||||
);
|
||||
}
|
||||
if (!skip.has('first-chat')) {
|
||||
const ping = await confirmAssistantResponds();
|
||||
if (ping === 'ok') {
|
||||
await runFirstChat();
|
||||
} else {
|
||||
renderPingFailureNote(ping);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('channel')) {
|
||||
@@ -200,7 +211,10 @@ async function main(): Promise<void> {
|
||||
await runDiscordChannel(displayName!);
|
||||
} else {
|
||||
p.log.info(
|
||||
"No messaging app for now. You can add one later (like Telegram, Discord, or Slack).",
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, or Slack).',
|
||||
4,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -216,6 +230,20 @@ async function main(): Promise<void> {
|
||||
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
|
||||
notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.');
|
||||
}
|
||||
const service = res.terminal?.fields.SERVICE;
|
||||
if (service === 'running_other_checkout') {
|
||||
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',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const agentPing = res.terminal?.fields.AGENT_PING;
|
||||
if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') {
|
||||
notes.push(
|
||||
@@ -223,6 +251,7 @@ async function main(): Promise<void> {
|
||||
'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
|
||||
notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.');
|
||||
}
|
||||
@@ -248,6 +277,95 @@ async function main(): Promise<void> {
|
||||
p.outro(k.green("You're ready! Enjoy NanoClaw."));
|
||||
}
|
||||
|
||||
// ─── first-chat step ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Round-trip ping against the CLI socket before we ask the user to chat.
|
||||
* Renders its own spinner with elapsed time because a cold-start container
|
||||
* boot can take 30–60s — the elapsed counter is the difference between
|
||||
* "patient" and "is this hung?". Returns the raw result so the caller can
|
||||
* branch between the chat loop (ok) and a diagnostic note (anything else).
|
||||
*/
|
||||
async function confirmAssistantResponds(): Promise<PingResult> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
const label = 'Waking your assistant…';
|
||||
s.start(label);
|
||||
const tick = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
s.message(`${label} ${k.dim(`(${elapsed}s)`)}`);
|
||||
}, 1000);
|
||||
|
||||
const result = await pingCliAgent();
|
||||
|
||||
clearInterval(tick);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
if (result === 'ok') {
|
||||
s.stop(`Your assistant is ready. ${k.dim(`(${elapsed}s)`)}`);
|
||||
} else {
|
||||
const msg =
|
||||
result === 'socket_error'
|
||||
? "Couldn't reach the NanoClaw service."
|
||||
: "Your assistant didn't reply in time.";
|
||||
s.stop(`${msg} ${k.dim(`(${elapsed}s)`)}`, 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderPingFailureNote(result: PingResult): void {
|
||||
const body =
|
||||
result === 'socket_error'
|
||||
? [
|
||||
wrapForGutter(
|
||||
"The NanoClaw service isn't listening on its local socket. Try restarting it, then chat with `pnpm run chat hi`:",
|
||||
6,
|
||||
),
|
||||
'',
|
||||
k.dim(' macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw'),
|
||||
k.dim(' Linux: systemctl --user restart nanoclaw'),
|
||||
].join('\n')
|
||||
: wrapForGutter(
|
||||
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
||||
6,
|
||||
);
|
||||
p.note(body, 'Skipping the first chat');
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat loop. Each message is piped through `pnpm run chat`, which uses
|
||||
* the same Unix-socket path the ping just exercised, so output streams
|
||||
* back inline as the agent replies. An empty input ends the loop.
|
||||
*/
|
||||
async function runFirstChat(): Promise<void> {
|
||||
while (true) {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Say something to your assistant',
|
||||
placeholder: 'press Enter with nothing to continue',
|
||||
}),
|
||||
);
|
||||
const text = ((answer as string | undefined) ?? '').trim();
|
||||
if (!text) return;
|
||||
await sendChatMessage(text);
|
||||
}
|
||||
}
|
||||
|
||||
function sendChatMessage(message: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// `pnpm --silent` suppresses the `> nanoclaw@… chat` preamble so the
|
||||
// agent's reply reads as a clean block under the prompt. Splitting on
|
||||
// whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv
|
||||
// with spaces on the far side.
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['--silent', 'run', 'chat', ...message.split(/\s+/)],
|
||||
{ stdio: ['ignore', 'inherit', 'inherit'] },
|
||||
);
|
||||
child.on('close', () => resolve());
|
||||
child.on('error', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// ─── auth step (select → branch) ────────────────────────────────────────
|
||||
|
||||
async function runAuthStep(): Promise<void> {
|
||||
@@ -440,7 +558,6 @@ function maybeReexecUnderSg(): void {
|
||||
|
||||
function printIntro(): void {
|
||||
const isReexec = process.env.NANOCLAW_REEXEC_SG === '1';
|
||||
const isBootstrapped = process.env.NANOCLAW_BOOTSTRAPPED === '1';
|
||||
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
|
||||
|
||||
if (isReexec) {
|
||||
@@ -450,18 +567,11 @@ function printIntro(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// When we were called via nanoclaw.sh, the wordmark + subtitle were
|
||||
// already printed in bash. Just open the clack gutter with a short,
|
||||
// neutral intro so the flow continues without duplication.
|
||||
if (isBootstrapped) {
|
||||
p.intro(k.dim("Let's get you set up."));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(` ${wordmark}`);
|
||||
console.log(` ${k.dim('Setting up your personal AI assistant')}`);
|
||||
p.intro(k.dim("Let's get you set up."));
|
||||
// Always include the wordmark inside the clack intro line. When bash ran
|
||||
// first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark
|
||||
// above us; the small repeat is worth it to keep the brand anchored at
|
||||
// the visible top of the clack session once the bash output scrolls away.
|
||||
p.intro(`${wordmark} ${k.dim("Let's get you set up.")}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
50
setup/install-claude.sh
Executable file
50
setup/install-claude.sh
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the Claude Code CLI on the host via the official native installer.
|
||||
# Invoked from setup/register-claude-token.sh when the user picks the
|
||||
# subscription auth path and `claude` is missing. The other two auth paths
|
||||
# (paste OAuth token, paste API key) don't need the CLI, so this runs on
|
||||
# demand rather than up front.
|
||||
#
|
||||
# The native installer is Node-independent (downloads a prebuilt binary to
|
||||
# ~/.local/bin) and is the path Anthropic documents. This matches the
|
||||
# pattern used by install-docker.sh / install-node.sh: the script itself is
|
||||
# the allowlisted unit; the curl | bash pipe lives inside it.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_CLAUDE ==="
|
||||
|
||||
if command -v claude >/dev/null 2>&1; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "CLAUDE_VERSION: $(claude --version 2>/dev/null || echo unknown)"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: curl not available."
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "STEP: claude-native-install"
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
|
||||
# Native installer writes to ~/.local/bin and appends a PATH line to the
|
||||
# user's rc file; that doesn't help this session, so put it on PATH now.
|
||||
if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
fi
|
||||
hash -r 2>/dev/null || true
|
||||
|
||||
if ! command -v claude >/dev/null 2>&1; then
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: claude not found on PATH after install."
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "CLAUDE_VERSION: $(claude --version 2>/dev/null || echo unknown)"
|
||||
echo "=== END ==="
|
||||
50
setup/lib/agent-ping.ts
Normal file
50
setup/lib/agent-ping.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Round-trip check against the CLI Unix socket.
|
||||
*
|
||||
* Shared by `setup/verify.ts` (end-of-run health check) and `setup/auto.ts`
|
||||
* (confirm the freshly-wired agent actually responds before prompting the
|
||||
* user to chat with it).
|
||||
*
|
||||
* Exit-code contract follows `scripts/chat.ts`:
|
||||
* 0 → got a reply on stdout
|
||||
* 2 → socket unreachable (service not running or wrong checkout)
|
||||
* 3 → no reply before chat.ts's own 120s hard stop
|
||||
* This wrapper also guards with its own timeout in case chat.ts hangs.
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
export type PingResult = 'ok' | 'no_reply' | 'socket_error';
|
||||
|
||||
export function pingCliAgent(timeoutMs = 30_000): Promise<PingResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('pnpm', ['run', 'chat', 'ping'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill('SIGKILL');
|
||||
resolve('no_reply');
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf-8');
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 2) resolve('socket_error');
|
||||
else if (code === 0 && stdout.trim().length > 0) resolve('ok');
|
||||
else resolve('no_reply');
|
||||
});
|
||||
child.on('error', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve('socket_error');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -37,3 +37,66 @@ export function brandChip(s: string): string {
|
||||
}
|
||||
return k.bgCyan(k.black(k.bold(s)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text so it fits inside clack's gutter without the terminal's soft
|
||||
* wrap breaking the `│ …` bar on long lines. Works on a single string with
|
||||
* embedded `\n`s; each logical line is wrapped independently.
|
||||
*
|
||||
* The `gutter` argument is the total horizontal overhead clack adds for
|
||||
* the component the text lives in (e.g. 4 for `p.log.*`'s `│ ` prefix;
|
||||
* 6-ish for `p.note`'s box). Caller picks it; we just subtract from
|
||||
* `process.stdout.columns` and hard-wrap at word boundaries.
|
||||
*/
|
||||
export function wrapForGutter(text: string, gutter: number): string {
|
||||
const cols = process.stdout.columns ?? 80;
|
||||
const width = Math.max(30, cols - gutter);
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => wrapLine(line, width))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap + dim together. Needed instead of `k.dim(wrapForGutter(...))`
|
||||
* because clack resets styling at each line break when rendering
|
||||
* multi-line log content — a single outer dim envelope only colors the
|
||||
* first line. Applying dim per-line gives each wrapped row its own
|
||||
* `\x1b[2m…\x1b[0m` envelope so the whole block reads as one block.
|
||||
*/
|
||||
export function dimWrap(text: string, gutter: number): string {
|
||||
return wrapForGutter(text, gutter)
|
||||
.split('\n')
|
||||
.map((line) => k.dim(line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
function visibleLength(s: string): number {
|
||||
return s.replace(ANSI_RE, '').length;
|
||||
}
|
||||
|
||||
function wrapLine(line: string, width: number): string {
|
||||
if (visibleLength(line) <= width) return line;
|
||||
const words = line.split(' ');
|
||||
const rows: string[] = [];
|
||||
let cur = '';
|
||||
let curLen = 0;
|
||||
for (const word of words) {
|
||||
const wLen = visibleLength(word);
|
||||
if (curLen === 0) {
|
||||
cur = word;
|
||||
curLen = wLen;
|
||||
} else if (curLen + 1 + wLen <= width) {
|
||||
cur += ' ' + word;
|
||||
curLen += 1 + wLen;
|
||||
} else {
|
||||
rows.push(cur);
|
||||
cur = word;
|
||||
curLen = wLen;
|
||||
}
|
||||
}
|
||||
if (cur) rows.push(cur);
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
@@ -25,8 +25,26 @@ HOST_PATTERN="${HOST_PATTERN:-api.anthropic.com}"
|
||||
|
||||
command -v onecli >/dev/null \
|
||||
|| { echo "onecli not found. Install it first (see /setup §4)." >&2; exit 1; }
|
||||
command -v claude >/dev/null \
|
||||
|| { echo "claude CLI not found. Install from https://claude.ai/download" >&2; exit 1; }
|
||||
|
||||
if ! command -v claude >/dev/null 2>&1; then
|
||||
echo "Claude Code CLI not found — installing it now (needed for subscription sign-in)…"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
if ! bash "$SCRIPT_DIR/install-claude.sh"; then
|
||||
echo >&2
|
||||
echo "Couldn't install the Claude Code CLI automatically." >&2
|
||||
echo "Install it manually with" >&2
|
||||
echo " curl -fsSL https://claude.ai/install.sh | bash" >&2
|
||||
echo "and re-run setup." >&2
|
||||
exit 1
|
||||
fi
|
||||
# install-claude.sh PATH additions are scoped to its own subshell; redo
|
||||
# them here so the rest of this script can see the fresh `claude` binary.
|
||||
if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
fi
|
||||
hash -r 2>/dev/null || true
|
||||
fi
|
||||
|
||||
command -v script >/dev/null \
|
||||
|| { echo "script(1) is required for PTY capture." >&2; exit 1; }
|
||||
|
||||
|
||||
114
setup/verify.ts
114
setup/verify.ts
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
|
||||
*/
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
@@ -14,6 +14,7 @@ import Database from 'better-sqlite3';
|
||||
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 {
|
||||
getPlatform,
|
||||
getServiceManager,
|
||||
@@ -29,19 +30,35 @@ export async function run(_args: string[]): Promise<void> {
|
||||
|
||||
log.info('Starting verification');
|
||||
|
||||
// 1. Check service status
|
||||
let service = 'not_found';
|
||||
// 1. Check service status + detect checkout mismatch.
|
||||
//
|
||||
// Why the mismatch matters: the host binds `<DATA_DIR>/cli.sock` relative
|
||||
// to the project root it was started from. If the running service is from
|
||||
// a sibling checkout (common for developers with multiple clones), this
|
||||
// repo's `data/cli.sock` won't exist — AGENT_PING would return a
|
||||
// misleading `socket_error`. Surface the mismatch directly instead.
|
||||
let service:
|
||||
| 'not_found'
|
||||
| 'stopped'
|
||||
| 'running'
|
||||
| 'running_other_checkout' = 'not_found';
|
||||
let runningFromPath: string | null = null;
|
||||
const mgr = getServiceManager();
|
||||
|
||||
if (mgr === 'launchd') {
|
||||
try {
|
||||
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
||||
if (output.includes('com.nanoclaw')) {
|
||||
// Check if it has a PID (actually running)
|
||||
const line = output.split('\n').find((l) => l.includes('com.nanoclaw'));
|
||||
if (line) {
|
||||
const pidField = line.trim().split(/\s+/)[0];
|
||||
service = pidField !== '-' && pidField ? 'running' : 'stopped';
|
||||
if (pidField !== '-' && pidField) {
|
||||
service = 'running';
|
||||
const pid = Number(pidField);
|
||||
if (Number.isInteger(pid) && pid > 0) {
|
||||
runningFromPath = resolveBinaryScript(pid);
|
||||
}
|
||||
} else {
|
||||
service = 'stopped';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -52,6 +69,18 @@ export async function run(_args: string[]): Promise<void> {
|
||||
try {
|
||||
execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' });
|
||||
service = 'running';
|
||||
try {
|
||||
const pidStr = execSync(
|
||||
`${prefix} show nanoclaw -p MainPID --value`,
|
||||
{ encoding: 'utf-8' },
|
||||
).trim();
|
||||
const pid = Number(pidStr);
|
||||
if (Number.isInteger(pid) && pid > 0) {
|
||||
runningFromPath = resolveBinaryScript(pid);
|
||||
}
|
||||
} catch {
|
||||
// couldn't read MainPID; leave runningFromPath null
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const output = execSync(`${prefix} list-unit-files`, {
|
||||
@@ -74,13 +103,23 @@ export async function run(_args: string[]): Promise<void> {
|
||||
if (raw && Number.isInteger(pid) && pid > 0) {
|
||||
process.kill(pid, 0);
|
||||
service = 'running';
|
||||
runningFromPath = resolveBinaryScript(pid);
|
||||
}
|
||||
} catch {
|
||||
service = 'stopped';
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info('Service status', { service });
|
||||
|
||||
if (
|
||||
service === 'running' &&
|
||||
runningFromPath &&
|
||||
!isPathInside(runningFromPath, projectRoot)
|
||||
) {
|
||||
service = 'running_other_checkout';
|
||||
}
|
||||
|
||||
log.info('Service status', { service, runningFromPath });
|
||||
|
||||
// 2. Check container runtime
|
||||
let containerRuntime = 'none';
|
||||
@@ -213,46 +252,27 @@ export async function run(_args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a one-word message through the CLI channel and check for a reply.
|
||||
* Silent by default — stdout/stderr of the child are captured but not
|
||||
* forwarded. Kills the child after 90s so verify can't hang on a wedged
|
||||
* agent (chat.ts's own timeout is 120s, which is too long for setup).
|
||||
* Given a PID, resolve the script path the process is executing (i.e. the
|
||||
* first `.js` / `.ts` / `.mjs` arg after `node`). Returns null on any
|
||||
* error — callers should treat null as "couldn't tell" and skip the
|
||||
* mismatch check rather than flag a false positive.
|
||||
*/
|
||||
function pingCliAgent(): Promise<'ok' | 'no_reply' | 'socket_error'> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('pnpm', ['run', 'chat', 'ping'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill('SIGKILL');
|
||||
resolve('no_reply');
|
||||
}, 90_000);
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf-8');
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
// chat.ts: exit 0 on reply, 2 on socket error, 3 on no reply.
|
||||
if (code === 2) {
|
||||
resolve('socket_error');
|
||||
} else if (code === 0 && stdout.trim().length > 0) {
|
||||
resolve('ok');
|
||||
} else {
|
||||
resolve('no_reply');
|
||||
function resolveBinaryScript(pid: number): string | null {
|
||||
try {
|
||||
// BSD ps (macOS) and util-linux both honour `-o command=` (full argv,
|
||||
// no header). Node argv: "node /path/to/dist/index.js ...".
|
||||
const out = execSync(`ps -p ${pid} -o command=`, {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
const tokens = out.split(/\s+/);
|
||||
const script = tokens.find((t) => /\.(js|mjs|cjs|ts)$/.test(t));
|
||||
return script ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
child.on('error', () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve('socket_error');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isPathInside(candidate: string, parent: string): boolean {
|
||||
const rel = path.relative(parent, candidate);
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user