Skip to content
Merged
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
1 change: 1 addition & 0 deletions Dockerfile.test
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ COPY package.json bun.lock* tsconfig*.json ./
COPY packages/cli/package.json packages/cli/tsconfig.json ./packages/cli/
COPY packages/cli/scripts/ ./packages/cli/scripts/
COPY packages/agent/package.json packages/agent/tsconfig.json ./packages/agent/
COPY packages/pi-tps-mail/package.json ./packages/pi-tps-mail/

RUN bun install --frozen-lockfile 2>/dev/null || bun install

Expand Down
99 changes: 99 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
"packages/*"
],
"scripts": {
"build": "cd packages/agent && bun run build && cd ../cli && bun run build",
"test": "cd packages/agent && bun test && cd ../cli && bun test",
"build": "cd packages/agent && bun run build && cd ../cli && bun run build && cd ../pi-tps-mail && bun run build",
"test": "cd packages/agent && bun test && cd ../cli && bun test && cd ../pi-tps-mail && bun test",
"lint": "cd packages/cli && bun run lint",
"lint:ci": "cd packages/cli && bun run lint:ci",
"tps": "node packages/cli/dist/bin/tps.js"
Expand Down
115 changes: 115 additions & 0 deletions packages/pi-tps-mail/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# @tpsdev-ai/pi-tps-mail

TPS Mail watcher for Pi dispatch with launcher delegation and hard timeout.

## Overview

This package lifts the watcher logic from the Ember launcher's `tps-mail-watcher.mjs` into a publishable npm package. It watches `~/.tps/mail/{agent}/new/` for new messages and dispatches them to Pi via the agent's launcher script.

## Two MANDATORY Invariants

### 1. Shell out to per-agent launcher script for model/provider/identity

The watcher **must never** directly invoke `pi` or configure provider/model/identity. Instead, it delegates to the per-agent launcher script (e.g., `~/agents/ember/bin/ember`) which owns that configuration.

```typescript
// ❌ WRONG — duplicate config logic
const child = spawn("pi", ["--model", "qwen3-coder", body]);

// ✅ CORRECT — delegate to launcher
const child = spawn(EMBER_LAUNCHER, [body], {
env: process.env,
cwd: `${HOME}/agents/ember`,
});
```

### 2. Hard timeout with SIGTERM + 5s grace + SIGKILL

Each dispatch must have a hard timeout (default 30 minutes). If the Pi process hangs, the watcher kills it and continues processing other messages.

```typescript
// Timeout kills with SIGTERM, then SIGKILL after 5s grace
const timer = setTimeout(() => {
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), 5_000);
}, DISPATCH_TIMEOUT_MS);
```

The loop **continues** after timeout — no silent stalls.

## API

### `WatchOptions`

```typescript
interface WatchOptions {
agent?: string; // Agent ID (default: "ember")
inboxRoot?: string; // Path to ~/.tps (default: process.env.HOME)
launcher?: string; // Path to launcher script (default: ~/agents/{agent}/bin/{agent})
timeoutMs?: number; // Dispatch timeout in ms (default: 1_800_000 = 30 min)
}
```

### `Watch Mail`

```typescript
import { watchMail } from "@tpsdev-ai/pi-tps-mail";

const watcher = watchMail({
agent: "ember",
timeoutMs: 1_800_000, // 30 minutes
});

// Watcher runs until stop() is called
process.on("SIGINT", () => watcher.stop());
process.on("SIGTERM", () => watcher.stop());
```

### `watchMail` behavior

1. Polls `~/.tps/mail/{agent}/new/` every 5 seconds
2. For each JSON file:
- Parses as `MailMessage` (id, from, body)
- Moves file to `~/.tps/mail/{agent}/cur/`
- Spawns launcher script with message body as argument
- Enforces hard timeout with SIGTERM → 5s grace → SIGKILL
- Sends reply via `tps mail send {from} {stdout}` on success
- Sends ack via `tps mail ack {id} {agent}` on success
3. Continues loop on errors (bad JSON, spawn failures, timeouts)
4. Gracefully exits on SIGINT/SIGTERM

## CLI

```bash
# Watch ember's inbox with default 30-min timeout
npx @tpsdev-ai/pi-tps-mail

# Custom agent with 10-minute timeout
npx @tpsdev-ai/pi-tps-mail --agent flint --timeout 600000

# Custom inbox root (e.g., for testing)
npx @tpsdev-ai/pi-tps-mail --inbox /private/tmp/tps-mail-test
```

## Tests

```bash
cd packages/pi-tps-mail

# Round-trip dispatch (slow — 30 min timeout)
bun test test/roundtrip.test.ts

# Hung child timeout (fast — overrides timeout to 2s)
bun test test/timeout.test.ts

# Bad JSON handling (fast — no timeout)
bun test test/bad-json.test.ts
```

## Files

- `./src/index.ts` — Public API exports
- `./src/watcher.ts` — Core watcher logic with launcher delegation + timeout
- `./src/bin.ts` — CLI entrypoint
- `./src/types.ts` — TypeScript interfaces
- `./test/` — Test suite
54 changes: 54 additions & 0 deletions packages/pi-tps-mail/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "@tpsdev-ai/pi-tps-mail",
"version": "0.1.0",
"description": "TPS Mail watcher for Pi dispatch with launcher delegation and hard timeout",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./watcher": {
"import": "./dist/watcher.js",
"types": "./dist/watcher.d.ts"
},
"./bin": {
"import": "./dist/bin.js",
"types": "./dist/bin.d.ts"
}
},
"bin": {
"tps-mail-watcher": "./dist/bin.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "bun test",
"lint": "biome lint ./src",
"lint:ci": "biome lint ./src --max-diagnostics=200"
},
"keywords": [
"agents",
"tps",
"mail",
"pi",
"watcher"
],
"license": "Apache-2.0",
"dependencies": {
"glob": "^10.4.5",
"minimist": "^1.2.8"
},
"devDependencies": {
"@biomejs/biome": "^2.4.4",
"@types/minimist": "^1.2.5",
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
},
"author": "tpsdev-ai",
"publishConfig": {
"access": "public"
}
}
67 changes: 67 additions & 0 deletions packages/pi-tps-mail/src/bin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// CLI entrypoint for pi-tps-mail watcher

import { watchMail } from "./watcher.js";
import minimist from "minimist";

function usage(): never {
console.error("Usage:");
console.error(" tps-mail-watcher [options]");
console.error("");
console.error("Options:");
console.error(" --agent <id> Agent ID to watch (default: ember)");
console.error(" --inbox <path> Path to ~/.tps directory (default: $HOME)");
console.error(" --launcher <path> Path to launcher script (default: ~/agents/{agent}/bin/{agent})");
console.error(" --timeout <ms> Dispatch timeout in ms (default: 1800000 = 30 min)");
console.error(" --help, -h Show this help message");
process.exit(0);
}

function parseArgs(args: string[]): { [key: string]: string | number | boolean } {
const parsed = minimist(args, {
string: ["agent", "inbox", "launcher"],
alias: {
h: "help",
},
});

// Convert numeric fields
const opts: { [key: string]: string | number | boolean } = parsed;
if (opts.timeout !== undefined) {
opts.timeout = Number(opts.timeout);
}

return opts;
}

async function main() {
const args = process.argv.slice(2);
const opts = parseArgs(args);

if (opts.help) usage();

const options: { agent?: string; inboxRoot?: string; launcher?: string; timeoutMs?: number } = {};

if (opts.agent) options.agent = String(opts.agent);
if (opts.inbox) options.inboxRoot = String(opts.inbox);
if (opts.launcher) options.launcher = String(opts.launcher);
if (opts.timeout) options.timeoutMs = Number(opts.timeout);

const watcher = watchMail(options);

// Graceful shutdown
const shutdown = () => {
watcher.stop();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

// Keep alive
await new Promise<void>(() => {});
}

main().catch((err) => {
console.error(`fatal: ${err.message}`);
process.exit(1);
});
3 changes: 3 additions & 0 deletions packages/pi-tps-mail/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Public API exports
export { watchMail } from "./watcher.js";
export type { MailMessage, MailWatcher, WatchOptions } from "./types.js";
35 changes: 35 additions & 0 deletions packages/pi-tps-mail/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Types for pi-tps-mail package

/** Message structure for TPS mail */
export interface MailMessage {
/** Unique message ID */
id: string;
/** Sender agent ID */
from: string;
/** Message body (usually a spec or task) */
body: string;
/** ISO timestamp */
timestamp?: string;
/** Recipient (if applicable) */
to?: string;
}

/** Watcher options */
export interface WatchOptions {
/** Agent ID to watch (default: "ember") */
agent?: string;
/** Path to ~/.tps directory (default: process.env.HOME) */
inboxRoot?: string;
/** Path to launcher script (default: ~/agents/{agent}/bin/{agent}) */
launcher?: string;
/** Arguments to pass to the launcher (default: message body only) */
launcherArgs?: string[];
/** Dispatch timeout in ms (default: 1_800_000 = 30 min) */
timeoutMs?: number;
}

/** Mail watcher handle */
export interface MailWatcher {
/** Stop the watcher */
stop(): void;
}
Loading
Loading