chore: drop runner shim and add committer helper

This commit is contained in:
Peter Steinberger
2025-12-09 17:24:25 +00:00
parent d04f7fc6e9
commit 1f19ca1665
25 changed files with 139 additions and 51 deletions

View File

@@ -1,3 +1,5 @@
READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
# Repository Guidelines
## Project Structure & Module Organization
@@ -25,6 +27,7 @@
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
## Commit & Pull Request Guidelines
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.

View File

@@ -45,7 +45,7 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because
- Removed Twilio support and all related commands/options (webhook/up/provider flags/wait-poll); CLAWDIS is Baileys Web-only.
### Changes
- Default agent handling now favors Pi RPC while falling back to the plain command runner for non-Pi invocations, keeping heartbeat/session plumbing intact.
- Default agent handling now favors Pi RPC while falling back to plain command execution for non-Pi invocations, keeping heartbeat/session plumbing intact.
- Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical.
- Status command reports web session health + session recipients; config paths are locked to `~/.clawdis` with session metadata stored under `~/.clawdis/sessions/`.
- Simplified send/agent/relay/heartbeat to web-only delivery; removed Twilio mocks/tests and dead code.

View File

@@ -30,7 +30,7 @@ swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
```
## Use as a library
Add swabble as a SwiftPM dependency and import the `Swabble` product to reuse the Speech pipeline, config loader, hook runner, and transcript store in your own app:
Add swabble as a SwiftPM dependency and import the `Swabble` product to reuse the Speech pipeline, config loader, hook executor, and transcript store in your own app:
```swift
// Package.swift

View File

@@ -10,7 +10,7 @@ public struct HookJob: Sendable {
}
}
public actor HookRunner {
public actor HookExecutor {
private let config: SwabbleConfig
private var lastRun: Date?
private let hostname: String

View File

@@ -46,8 +46,8 @@ struct ServeCommand: ParsableCommand {
}
let stripped = Self.stripWake(text: seg.text, cfg: cfg)
let job = HookJob(text: stripped, timestamp: Date())
let runner = HookRunner(config: cfg)
try await runner.run(job: job)
let executor = HookExecutor(config: cfg)
try await executor.run(job: job)
if cfg.transcripts.enabled {
await TranscriptsStore.shared.append(text: stripped)
}

View File

@@ -21,8 +21,8 @@ struct TestHookCommand: ParsableCommand {
mutating func run() async throws {
let cfg = try ConfigLoader.load(at: configURL)
let runner = HookRunner(config: cfg)
try await runner.run(job: HookJob(text: text, timestamp: Date()))
let executor = HookExecutor(config: cfg)
try await executor.run(job: HookJob(text: text, timestamp: Date()))
print("hook invoked")
}

View File

@@ -18,7 +18,7 @@ Goal: brabble-style always-on voice hook for macOS 26 using Apple Speech.framewo
- **Config**: `SwabbleConfig` Codable. Fields: audio device name/index, wake (enabled/word/aliases/sensitivity placeholder), hook (command/args/prefix/cooldown/min_chars/timeout/env), logging (level, format), transcripts (enabled, max kept), speech (locale, enableEtiquetteReplacements flag). Stored JSON; default written by `setup`.
- **Audio + Speech pipeline**: `SpeechPipeline` wraps `AVAudioEngine` input → `SpeechAnalyzer` with `SpeechTranscriber` module. Emits partial/final transcripts via async stream. Requests `.audioTimeRange` when transcripts enabled. Handles Speech permission and asset download prompts ahead of capture.
- **Wake gate**: text-based keyword match against latest partial/final; strips wake term before hook dispatch. `--no-wake` disables.
- **Hook runner**: async `HookRunner` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
- **Hook executor**: async `HookExecutor` spawns `Process` with configured args, prefix substitution `${hostname}`. Enforces cooldown + timeout; injects env `SWABBLE_TEXT`, `SWABBLE_PREFIX` plus user env map.
- **Transcripts store**: in-memory ring buffer; optional persisted JSON lines under `~/Library/Application Support/swabble/transcripts.log`.
- **Logging**: simple structured logger to stderr; respects log level.

View File

@@ -466,7 +466,7 @@ extension GeneralSettings {
}
// Step 1: basic SSH reachability check
let sshResult = await ShellRunner.run(
let sshResult = await ShellExecutor.run(
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
cwd: nil,
env: nil,

View File

@@ -129,7 +129,7 @@ enum RelayEnvironment {
let cmd = [pnpm, "add", "-g", "clawdis@\(target)"]
statusHandler("Installing clawdis@\(target) via pnpm…")
let response = await ShellRunner.run(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300)
let response = await ShellExecutor.run(command: cmd, cwd: nil, env: ["PATH": preferred], timeout: 300)
if response.ok {
statusHandler("Installed clawdis@\(target)")
} else {

View File

@@ -1,7 +1,7 @@
import ClawdisIPC
import Foundation
enum ShellRunner {
enum ShellExecutor {
static func run(command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> Response {
guard !command.isEmpty else { return Response(ok: false, message: "empty command") }

View File

@@ -70,7 +70,7 @@ final class ClawdisXPCService: NSObject, ClawdisXPCProtocol {
.ensure([.screenRecording], interactive: false)[.screenRecording] ?? false
guard authorized else { return Response(ok: false, message: "screen recording permission missing") }
}
return await ShellRunner.run(command: command, cwd: cwd, env: env, timeout: timeoutSec)
return await ShellExecutor.run(command: command, cwd: cwd, env: env, timeout: timeoutSec)
case let .agent(message, thinking, session, deliver, to):
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -9,7 +9,7 @@
- **mcporter** — MCP runtime/CLI to list, call, and sync Model Context Protocol servers.
- **Peekaboo** — Fast macOS screenshots with optional AI vision analysis.
- **camsnap** — Capture frames, clips, or motion alerts from RTSP/ONVIF security cams.
- **oracle** — OpenAI-ready agent runner with session replay and browser control.
- **oracle** — OpenAI-ready agent CLI with session replay and browser control.
- **eightctl** — Control Eight Sleep Pod temperature, alarms, schedules, and metrics.
- **imsg** — macOS Messages CLI to read/tail chats and send iMessage/SMS.
- **spotify-player** — Terminal Spotify client to search/queue/control playback.

View File

@@ -1,6 +1,6 @@
# Plan: `clawdis agent` (direct-to-agent invocation)
Goal: Add a CLI subcommand that talks directly to the configured agent/command runner (no WhatsApp send), while reusing the same session handling and config clawdis already uses for auto-replies.
Goal: Add a CLI subcommand that talks directly to the configured agent command (no WhatsApp send), while reusing the same session handling and config clawdis already uses for auto-replies.
## Why
- Sometimes we want to poke the agent directly (same prompt templates/sessions) without sending a WhatsApp message.

View File

@@ -15,7 +15,7 @@ Last updated: 2025-12-09
- **Clients (mac app / CLI / web admin)**
- One WS connection per client.
- Send requests (`health`, `status`, `send`, `agent`, `system-presence`, toggles) and subscribe to events (`tick`, `agent`, `presence`, `shutdown`).
- **Agent runner (Tau/Pi process)**
- **Agent process (Tau/Pi)**
- Spawned by the Gateway on demand for `agent` calls; streams events back over the same WS connection.
- **WebChat**
- Serves static assets locally.

View File

@@ -53,7 +53,7 @@ struct Response { ok: Bool; message?: String; payload?: Data }
- NotificationManager: UNUserNotificationCenter primary; AppleScript `display notification` fallback; respects the `--sound` value on each request.
- PermissionManager: checks/requests Notifications, Accessibility (AX), Screen Recording (capture probe); publishes changes for UI.
- ScreenCaptureManager: window/display PNG capture; gated on permission.
- ShellRunner: executes `Process` with timeout; rejects when `needsScreenRecording` and permission missing; returns stdout/stderr in payload.
- ShellExecutor: executes `Process` with timeout; rejects when `needsScreenRecording` and permission missing; returns stdout/stderr in payload.
- XPCListener actor: routes Request → managers; logs via OSLog.
## CLI (`clawdis-mac`)

View File

@@ -3,7 +3,7 @@
Updated: 2025-12-07
# Why grammY
- TS-first Bot API client with built-in long-poll + webhook runners, middleware, error handling, rate limiter.
- TS-first Bot API client with built-in long-poll + webhook helpers, middleware, error handling, rate limiter.
- Cleaner media helpers than hand-rolling fetch + FormData; supports all Bot API methods.
- Extensible: proxy support via custom fetch, session middleware (optional), type-safe context.

View File

@@ -15,7 +15,7 @@ How to see whether the WhatsApp Web/Baileys bridge is healthy from the menu bar
- Uses a cached snapshot so the UI loads instantly and falls back gracefully when offline.
## How the probe works
- App runs `clawdis health --json` via `ShellRunner` every ~60s and on demand. The probe loads creds, attempts a short Baileys connect, and reports status without sending messages.
- App runs `clawdis health --json` via `ShellExecutor` every ~60s and on demand. The probe loads creds, attempts a short Baileys connect, and reports status without sending messages.
- Cache the last good snapshot and the last error separately to avoid flicker; show the timestamp of each.
## When in doubt

View File

@@ -66,7 +66,7 @@ LOG CATEGORIES (examples):
• xpc - XPC service calls
• notifications - Notification helper
• screenshot - Screenshotter
• shell - ShellRunner
• shell - ShellExecutor
QUICK START:
vtlog -n 100 Show last 100 lines from all components

107
scripts/committer Executable file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -euo pipefail
# Disable glob expansion to handle brackets in file paths
set -f
usage() {
printf 'Usage: %s [--force] "commit message" "file" ["file" ...]\n' "$(basename "$0")" >&2
exit 2
}
if [ "$#" -lt 2 ]; then
usage
fi
force_delete_lock=false
if [ "${1:-}" = "--force" ]; then
force_delete_lock=true
shift
fi
if [ "$#" -lt 2 ]; then
usage
fi
commit_message=$1
shift
if [[ "$commit_message" != *[![:space:]]* ]]; then
printf 'Error: commit message must not be empty\n' >&2
exit 1
fi
if [ -e "$commit_message" ]; then
printf 'Error: first argument looks like a file path ("%s"); provide the commit message first\n' "$commit_message" >&2
exit 1
fi
if [ "$#" -eq 0 ]; then
usage
fi
files=("$@")
# Disallow "." because it stages the entire repository and defeats the helper's safety guardrails.
for file in "${files[@]}"; do
if [ "$file" = "." ]; then
printf 'Error: "." is not allowed; list specific paths instead\n' >&2
exit 1
fi
done
last_commit_error=''
run_git_commit() {
local stderr_log
stderr_log=$(mktemp)
if git commit -m "$commit_message" -- "${files[@]}" 2> >(tee "$stderr_log" >&2); then
rm -f "$stderr_log"
last_commit_error=''
return 0
fi
last_commit_error=$(cat "$stderr_log")
rm -f "$stderr_log"
return 1
}
for file in "${files[@]}"; do
if [ ! -e "$file" ]; then
if ! git ls-files --error-unmatch -- "$file" >/dev/null 2>&1; then
printf 'Error: file not found: %s\n' "$file" >&2
exit 1
fi
fi
done
git restore --staged :/
git add --force -- "${files[@]}"
if git diff --staged --quiet; then
printf 'Warning: no staged changes detected for: %s\n' "${files[*]}" >&2
exit 1
fi
committed=false
if run_git_commit; then
committed=true
elif [ "$force_delete_lock" = true ]; then
lock_path=$(
printf '%s\n' "$last_commit_error" |
awk -F"'" '/Unable to create .*\.git\/index\.lock/ { print $2; exit }'
)
if [ -n "$lock_path" ] && [ -e "$lock_path" ]; then
rm -f "$lock_path"
printf 'Removed stale git lock: %s\n' "$lock_path" >&2
if run_git_commit; then
committed=true
fi
fi
fi
if [ "$committed" = false ]; then
exit 1
fi
printf 'Committed "%s" with %d files\n' "$commit_message" "${#files[@]}"

View File

@@ -62,7 +62,6 @@ describe("runCommandReply (pi)", () => {
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
thinkLevel: "medium",
});
@@ -100,7 +99,6 @@ describe("runCommandReply (pi)", () => {
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
});
@@ -142,7 +140,6 @@ describe("runCommandReply (pi)", () => {
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
});
@@ -183,7 +180,6 @@ describe("runCommandReply (pi)", () => {
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
});
@@ -240,7 +236,6 @@ describe("runCommandReply (pi)", () => {
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
onAgentEvent: (evt) => events.push(evt),
});
@@ -281,7 +276,6 @@ describe("runCommandReply (pi)", () => {
systemSent: true,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
});
@@ -311,7 +305,6 @@ describe("runCommandReply (pi)", () => {
systemSent: false,
timeoutMs: 10,
timeoutSeconds: 1,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
});
@@ -344,7 +337,6 @@ describe("runCommandReply (pi)", () => {
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
});
@@ -379,7 +371,6 @@ describe("runCommandReply (pi)", () => {
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
});
@@ -411,7 +402,6 @@ describe("runCommandReply (pi)", () => {
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
onPartialReply: onPartial,
verboseLevel: "off",
@@ -445,7 +435,6 @@ describe("runCommandReply (pi)", () => {
systemSent: false,
timeoutMs: 1000,
timeoutSeconds: 1,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
});
@@ -475,7 +464,6 @@ describe("runCommandReply (pi)", () => {
systemSent: false,
timeoutMs: 100,
timeoutSeconds: 1,
commandRunner: vi.fn(),
enqueue: enqueueImmediate,
});

View File

@@ -15,7 +15,6 @@ import { logError } from "../logger.js";
import { getChildLogger } from "../logging.js";
import { splitMediaFromOutput } from "../media/parse.js";
import { enqueueCommand } from "../process/command-queue.js";
import type { runCommandWithTimeout } from "../process/exec.js";
import { runPiRpc } from "../process/tau-rpc.js";
import { applyTemplate, type TemplateContext } from "./templating.js";
import {
@@ -146,7 +145,7 @@ type CommandReplyConfig = NonNullable<WarelayConfig["inbound"]>["reply"] & {
mode: "command";
};
type EnqueueRunner = typeof enqueueCommand;
type EnqueueCommandFn = typeof enqueueCommand;
type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
@@ -159,8 +158,7 @@ type CommandReplyParams = {
systemSent: boolean;
timeoutMs: number;
timeoutSeconds: number;
commandRunner: typeof runCommandWithTimeout;
enqueue?: EnqueueRunner;
enqueue?: EnqueueCommandFn;
thinkLevel?: ThinkLevel;
verboseLevel?: "off" | "on";
onPartialReply?: (payload: ReplyPayload) => Promise<void> | void;
@@ -347,7 +345,6 @@ export async function runCommandReply(
systemSent,
timeoutMs,
timeoutSeconds,
commandRunner: _commandRunner,
enqueue = enqueueCommand,
thinkLevel,
verboseLevel,

View File

@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import * as tauRpc from "../process/tau-rpc.js";
import * as commandReply from "./command-reply.js";
import { getReplyFromConfig } from "./reply.js";
const webMocks = vi.hoisted(() => ({
@@ -27,7 +28,7 @@ afterEach(() => {
describe("trigger handling", () => {
it("aborts even with timestamp prefix", async () => {
const runner = vi.fn();
const commandSpy = vi.spyOn(commandReply, "runCommandReply");
const res = await getReplyFromConfig(
{
Body: "[Dec 5 10:00] stop",
@@ -36,15 +37,14 @@ describe("trigger handling", () => {
},
{},
baseCfg,
runner,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("⚙️ Agent was aborted.");
expect(runner).not.toHaveBeenCalled();
expect(commandSpy).not.toHaveBeenCalled();
});
it("restarts even with prefix/whitespace", async () => {
const runner = vi.fn();
const commandSpy = vi.spyOn(commandReply, "runCommandReply");
const res = await getReplyFromConfig(
{
Body: " [Dec 5] /restart",
@@ -53,15 +53,14 @@ describe("trigger handling", () => {
},
{},
baseCfg,
runner,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text?.startsWith("⚙️ Restarting" ?? "")).toBe(true);
expect(runner).not.toHaveBeenCalled();
expect(commandSpy).not.toHaveBeenCalled();
});
it("reports status without invoking the agent", async () => {
const runner = vi.fn();
const commandSpy = vi.spyOn(commandReply, "runCommandReply");
const res = await getReplyFromConfig(
{
Body: "/status",
@@ -70,11 +69,10 @@ describe("trigger handling", () => {
},
{},
baseCfg,
runner,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Status");
expect(runner).not.toHaveBeenCalled();
expect(commandSpy).not.toHaveBeenCalled();
});
it("ignores think directives that only appear in the context wrapper", async () => {

View File

@@ -17,7 +17,6 @@ import { isVerbose, logVerbose } from "../globals.js";
import { buildProviderSummary } from "../infra/provider-summary.js";
import { triggerWarelayRestart } from "../infra/restart.js";
import { drainSystemEvents } from "../infra/system-events.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime } from "../runtime.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
@@ -163,7 +162,6 @@ export async function getReplyFromConfig(
ctx: MsgContext,
opts?: GetReplyOptions,
configOverride?: WarelayConfig,
commandRunner: typeof runCommandWithTimeout = runCommandWithTimeout,
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
// Choose reply from config: static text or external command stdout.
const cfg = configOverride ?? loadConfig();
@@ -737,7 +735,6 @@ export async function getReplyFromConfig(
systemSent,
timeoutMs,
timeoutSeconds,
commandRunner,
thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel,
onPartialReply: opts?.onPartialReply,

View File

@@ -22,7 +22,6 @@ import {
saveSessionStore,
} from "../config/sessions.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { normalizeE164 } from "../utils.js";
@@ -319,7 +318,6 @@ export async function agentCommand(
systemSent,
timeoutMs,
timeoutSeconds,
commandRunner: runCommandWithTimeout,
thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel,
runId: sessionId,

View File

@@ -101,7 +101,7 @@ describe("web inbound media saves with extension", () => {
realSock.ev.emit("messages.upsert", upsert);
// Allow a brief window for the async handler to fire on slower runners.
// Allow a brief window for the async handler to fire on slower hosts.
for (let i = 0; i < 10; i++) {
if (onMessage.mock.calls.length > 0) break;
await new Promise((resolve) => setTimeout(resolve, 5));