@inceptionstack/roundhouse 0.5.7 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/cli.ts +3 -0
- package/src/cli/cron-commands.ts +16 -2
- package/src/cli/message.ts +41 -0
- package/src/cron/runner.ts +16 -7
- package/src/cron/scheduler.ts +13 -4
- package/src/gateway/gateway.ts +40 -0
- package/src/gateway/tools.md +114 -0
- package/src/ipc/client.ts +59 -0
- package/src/ipc/index.ts +3 -0
- package/src/ipc/server.ts +102 -0
- package/src/ipc/types.ts +13 -0
- package/src/provisioning/bundle.ts +31 -0
package/package.json
CHANGED
package/src/cli/cli.ts
CHANGED
|
@@ -370,6 +370,7 @@ Commands:
|
|
|
370
370
|
doctor [--fix] Check system health and configuration
|
|
371
371
|
Options: --fix, --json, --verbose
|
|
372
372
|
cron <command> Manage scheduled jobs (add, list, trigger, etc.)
|
|
373
|
+
message "text" Send a message to active transports via gateway
|
|
373
374
|
|
|
374
375
|
Config:
|
|
375
376
|
~/.roundhouse/gateway.config.json
|
|
@@ -388,6 +389,7 @@ import { cmdDoctor } from "./doctor";
|
|
|
388
389
|
import { cmdAgent } from "./agent-command";
|
|
389
390
|
import { cmdCron } from "./cron";
|
|
390
391
|
import { cmdSetup, cmdPair } from "./setup";
|
|
392
|
+
import { cmdMessage } from "./message";
|
|
391
393
|
|
|
392
394
|
const command = process.argv[2];
|
|
393
395
|
|
|
@@ -407,6 +409,7 @@ const commands: Record<string, () => void | Promise<void>> = {
|
|
|
407
409
|
tui: cmdTui,
|
|
408
410
|
doctor: () => cmdDoctor(process.argv.slice(3)),
|
|
409
411
|
cron: () => cmdCron(process.argv.slice(3)),
|
|
412
|
+
message: () => cmdMessage(process.argv.slice(3)),
|
|
410
413
|
agent: cmdAgent,
|
|
411
414
|
};
|
|
412
415
|
|
package/src/cli/cron-commands.ts
CHANGED
|
@@ -105,9 +105,23 @@ export async function cronAdd(store: CronStore, positional: string[], flags: Rec
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export async function cronList(store: CronStore, _positional: string[], flags: Record<string, string>): Promise<void> {
|
|
108
|
-
|
|
108
|
+
let jobs = await store.listJobs();
|
|
109
|
+
|
|
110
|
+
// Hide completed one-shot jobs unless --all flag is set
|
|
111
|
+
if (!flags.all) {
|
|
112
|
+
const filtered: typeof jobs = [];
|
|
113
|
+
for (const j of jobs) {
|
|
114
|
+
if (j.schedule.type === "once") {
|
|
115
|
+
const state = await store.getState(j.id);
|
|
116
|
+
if (state.totalRuns > 0) continue;
|
|
117
|
+
}
|
|
118
|
+
filtered.push(j);
|
|
119
|
+
}
|
|
120
|
+
jobs = filtered;
|
|
121
|
+
}
|
|
122
|
+
|
|
109
123
|
if (jobs.length === 0) {
|
|
110
|
-
console.log("No cron jobs
|
|
124
|
+
console.log("No active cron jobs.");
|
|
111
125
|
return;
|
|
112
126
|
}
|
|
113
127
|
if (flags.json) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/message.ts — Send a message to the running gateway via IPC
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* roundhouse message "Hello from CLI"
|
|
6
|
+
* roundhouse message --session main "Hello"
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { sendIpc } from "../ipc";
|
|
10
|
+
|
|
11
|
+
export async function cmdMessage(args: string[]): Promise<void> {
|
|
12
|
+
let session: string | undefined;
|
|
13
|
+
const positional: string[] = [];
|
|
14
|
+
|
|
15
|
+
for (let i = 0; i < args.length; i++) {
|
|
16
|
+
if (args[i] === "--session" && args[i + 1]) {
|
|
17
|
+
session = args[++i];
|
|
18
|
+
} else {
|
|
19
|
+
positional.push(args[i]);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const text = positional.join(" ").trim();
|
|
24
|
+
if (!text) {
|
|
25
|
+
console.error('Usage: roundhouse message [--session <name>] "<message>"');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const response = await sendIpc({ type: "notify", text, session });
|
|
31
|
+
if (response.ok) {
|
|
32
|
+
console.log("✅ Message delivered to gateway");
|
|
33
|
+
} else {
|
|
34
|
+
console.error(`❌ ${response.error}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
} catch (err: any) {
|
|
38
|
+
console.error(`❌ ${err.message}`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/cron/runner.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { getAgentFactory } from "../agents/registry";
|
|
9
|
-
// TODO: route through TransportAdapter.notify() when multi-transport lands
|
|
10
9
|
import { sendTelegramToMany } from "../transports/telegram/notify";
|
|
10
|
+
import { sendIpc } from "../ipc";
|
|
11
11
|
import { CronStore, generateRunId } from "./store";
|
|
12
12
|
import { buildTemplateContext, renderTemplate } from "./template";
|
|
13
13
|
import type { CronJobConfig, CronRunRecord } from "./types";
|
|
@@ -127,11 +127,6 @@ export class CronRunner {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
private async notify(job: CronJobConfig, record: CronRunRecord): Promise<void> {
|
|
130
|
-
const tg = job.notify?.telegram;
|
|
131
|
-
if (!tg?.chatIds?.length) return;
|
|
132
|
-
|
|
133
|
-
if (!shouldNotify(tg.onlyOn, record.status)) return;
|
|
134
|
-
|
|
135
130
|
const icon = runStatusIcon(record.status);
|
|
136
131
|
const dur = `${(record.durationMs / 1000).toFixed(1)}s`;
|
|
137
132
|
const header = `${icon} Cron: ${job.id}\nStatus: ${record.status} (${dur})`;
|
|
@@ -145,6 +140,20 @@ export class CronRunner {
|
|
|
145
140
|
body = `${header}\nError: ${record.error.slice(0, NOTIFY_MAX_ERROR_CHARS)}`;
|
|
146
141
|
}
|
|
147
142
|
|
|
148
|
-
|
|
143
|
+
// Route 1: Explicit Telegram chat IDs configured on the job
|
|
144
|
+
const tg = job.notify?.telegram;
|
|
145
|
+
if (tg?.chatIds?.length) {
|
|
146
|
+
if (!shouldNotify(tg.onlyOn, record.status)) return;
|
|
147
|
+
await sendTelegramToMany(tg.chatIds, body);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Route 2: Broadcast via IPC to all active transports (default)
|
|
152
|
+
try {
|
|
153
|
+
await sendIpc({ type: "notify", text: body });
|
|
154
|
+
} catch {
|
|
155
|
+
// Gateway not running or IPC unavailable — log and continue
|
|
156
|
+
console.warn(`[cron] ${job.id} IPC notify failed (gateway not running?)`);
|
|
157
|
+
}
|
|
149
158
|
}
|
|
150
159
|
}
|
package/src/cron/scheduler.ts
CHANGED
|
@@ -88,10 +88,19 @@ export class CronSchedulerService {
|
|
|
88
88
|
|
|
89
89
|
async listJobs(): Promise<Array<{ job: CronJobConfig; state: CronJobState }>> {
|
|
90
90
|
await this.reload();
|
|
91
|
-
return this.jobs
|
|
92
|
-
job
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
return this.jobs
|
|
92
|
+
.filter((job) => {
|
|
93
|
+
// Hide completed one-shot jobs (already fired, won't run again)
|
|
94
|
+
if (job.schedule.type === "once") {
|
|
95
|
+
const state = this.states.get(job.id);
|
|
96
|
+
if (state && state.totalRuns > 0) return false;
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
})
|
|
100
|
+
.map((job) => ({
|
|
101
|
+
job,
|
|
102
|
+
state: this.states.get(job.id) ?? emptyState(job.id),
|
|
103
|
+
}));
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
async trigger(jobId: string): Promise<void> {
|
package/src/gateway/gateway.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { SttService, enrichAttachmentsWithTranscripts, DEFAULT_STT_CONFIG } from
|
|
|
13
13
|
import { runDoctor, formatDoctorTelegram, createDoctorContext } from "../cli/doctor/runner";
|
|
14
14
|
import { ROUNDHOUSE_DIR, ROUNDHOUSE_VERSION } from "../config";
|
|
15
15
|
import { CronSchedulerService } from "../cron/scheduler";
|
|
16
|
+
import { IpcServer, type IpcRequest } from "../ipc";
|
|
16
17
|
import { prepareMemoryForTurn, finalizeMemoryForTurn, flushMemoryThenCompact } from "../memory/lifecycle";
|
|
17
18
|
import { maxPressure } from "../memory/policy";
|
|
18
19
|
import type { PressureLevel } from "../memory/types";
|
|
@@ -82,6 +83,7 @@ export class Gateway {
|
|
|
82
83
|
private pairingComplete = false;
|
|
83
84
|
private sttService: SttService | null = null;
|
|
84
85
|
private cronScheduler: CronSchedulerService | null = null;
|
|
86
|
+
private ipcServer: IpcServer | null = null;
|
|
85
87
|
|
|
86
88
|
constructor(router: AgentRouter, config: GatewayConfig) {
|
|
87
89
|
this.router = router;
|
|
@@ -330,6 +332,41 @@ export class Gateway {
|
|
|
330
332
|
console.error("[roundhouse] cron scheduler start failed:", (err as Error).message);
|
|
331
333
|
}
|
|
332
334
|
|
|
335
|
+
// Start IPC server for CLI → gateway communication
|
|
336
|
+
this.ipcServer = new IpcServer(async (req: IpcRequest) => {
|
|
337
|
+
if (req.type === "ping") return { ok: true };
|
|
338
|
+
if (req.type === "notify") {
|
|
339
|
+
const allChatIds = this.config.chat.notifyChatIds ?? [];
|
|
340
|
+
if (allChatIds.length === 0) return { ok: false, error: "No notifyChatIds configured" };
|
|
341
|
+
|
|
342
|
+
// Session routing:
|
|
343
|
+
// "main" = first notifyChatId (primary user chat)
|
|
344
|
+
// numeric string = that specific chat ID
|
|
345
|
+
// anything else / undefined = broadcast to all notifyChatIds
|
|
346
|
+
let targetIds: number[];
|
|
347
|
+
if (req.session === "main") {
|
|
348
|
+
targetIds = [allChatIds[0]];
|
|
349
|
+
} else if (req.session && /^-?\d+$/.test(req.session)) {
|
|
350
|
+
targetIds = [Number(req.session)];
|
|
351
|
+
} else {
|
|
352
|
+
targetIds = allChatIds; // broadcast to all
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
await this.transport.notify(targetIds, req.text);
|
|
357
|
+
return { ok: true };
|
|
358
|
+
} catch (e: any) {
|
|
359
|
+
return { ok: false, error: e.message };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { ok: false, error: "Unknown request type" };
|
|
363
|
+
});
|
|
364
|
+
try {
|
|
365
|
+
await this.ipcServer.start();
|
|
366
|
+
} catch (err) {
|
|
367
|
+
console.error("[roundhouse] IPC server start failed:", (err as Error).message);
|
|
368
|
+
}
|
|
369
|
+
|
|
333
370
|
// Send startup notification (after cron init so we can include job counts)
|
|
334
371
|
await this.notifyStartup(platforms);
|
|
335
372
|
}
|
|
@@ -746,6 +783,9 @@ export class Gateway {
|
|
|
746
783
|
}
|
|
747
784
|
|
|
748
785
|
async stop() {
|
|
786
|
+
if (this.ipcServer) {
|
|
787
|
+
this.ipcServer.stop();
|
|
788
|
+
}
|
|
749
789
|
if (this.cronScheduler) {
|
|
750
790
|
try { await this.cronScheduler.stop(); } catch (e) { console.warn("[roundhouse] cron stop error:", e); }
|
|
751
791
|
}
|
package/src/gateway/tools.md
CHANGED
|
@@ -52,3 +52,117 @@ Users can also manage jobs via Telegram:
|
|
|
52
52
|
- `/crons trigger <id>` — run now
|
|
53
53
|
- `/crons pause <id>` — disable
|
|
54
54
|
- `/crons resume <id>` — enable
|
|
55
|
+
|
|
56
|
+
## roundhouse message
|
|
57
|
+
|
|
58
|
+
Send a message to the user via all active transports (Telegram, etc.) without spawning an agent turn.
|
|
59
|
+
|
|
60
|
+
**Usage:**
|
|
61
|
+
```bash
|
|
62
|
+
roundhouse message "Hello from the server!"
|
|
63
|
+
roundhouse message --session main "Targeted to primary chat"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Git & GitHub (gh CLI)
|
|
67
|
+
|
|
68
|
+
Use `gh` CLI for all GitHub operations. It handles authentication automatically.
|
|
69
|
+
|
|
70
|
+
**Common patterns:**
|
|
71
|
+
```bash
|
|
72
|
+
# Push branches
|
|
73
|
+
git push origin <branch>
|
|
74
|
+
|
|
75
|
+
# Create PRs
|
|
76
|
+
gh pr create --base main --head <branch> --title "..." --body "..."
|
|
77
|
+
|
|
78
|
+
# Merge PRs
|
|
79
|
+
gh pr merge <number> --squash --admin
|
|
80
|
+
|
|
81
|
+
# Check CI status
|
|
82
|
+
gh pr checks <number>
|
|
83
|
+
gh run list --limit 5
|
|
84
|
+
|
|
85
|
+
# Create releases / tags
|
|
86
|
+
git tag v1.2.3 && git push origin v1.2.3
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Prefer `gh` over raw git for:**
|
|
90
|
+
- PR creation and merging
|
|
91
|
+
- CI/workflow status checks
|
|
92
|
+
- Release creation
|
|
93
|
+
- Repository settings
|
|
94
|
+
|
|
95
|
+
## mcporter (MCP Server CLI)
|
|
96
|
+
|
|
97
|
+
Call tools from configured MCP servers (AWS APIs, docs, infrastructure).
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
mcporter list # show available servers
|
|
101
|
+
mcporter list <server> --schema # show tools + parameters
|
|
102
|
+
mcporter call <server>.<tool> key=val # call a tool
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Examples:**
|
|
106
|
+
```bash
|
|
107
|
+
mcporter call 'aws-mcp.sts_GetCallerIdentity()'
|
|
108
|
+
mcporter call aws-mcp.s3_ListBuckets
|
|
109
|
+
mcporter call 'aws-documentation.search(query: "Lambda timeout")'
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
> Ensure PATH includes `~/.local/bin` for `mcporter`/`uvx` discovery.
|
|
113
|
+
|
|
114
|
+
## playwright-cli (Browser Automation)
|
|
115
|
+
|
|
116
|
+
Headless browser automation for testing web UIs, scraping, screenshots.
|
|
117
|
+
|
|
118
|
+
**Core workflow:** open → snapshot → interact → close
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
playwright-cli open "https://example.com" # launch + navigate
|
|
122
|
+
playwright-cli snapshot # accessibility tree with [ref=eN]
|
|
123
|
+
playwright-cli click e5 # click element by ref
|
|
124
|
+
playwright-cli fill e3 "search query" # type into input
|
|
125
|
+
playwright-cli screenshot # save viewport PNG
|
|
126
|
+
playwright-cli close # close browser
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Other commands:**
|
|
130
|
+
```bash
|
|
131
|
+
playwright-cli requests # list network requests
|
|
132
|
+
playwright-cli request <index> # show request details
|
|
133
|
+
playwright-cli cookie-list # list cookies
|
|
134
|
+
playwright-cli eval "document.title" # run JS in page
|
|
135
|
+
playwright-cli pdf # save page as PDF
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**Use for:** E2E testing, visual verification, form automation, web scraping.
|
|
139
|
+
**NOT for:** API-only testing (use curl), static file reading.
|
|
140
|
+
|
|
141
|
+
## codex exec
|
|
142
|
+
|
|
143
|
+
Delegate tasks to Codex CLI (architecture design, parallel research, code review).
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
codex exec "Design a retry mechanism with exponential backoff for this module"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Good for: brainstorming, getting a second opinion, architecture decisions, reducing bikeshedding.
|
|
150
|
+
|
|
151
|
+
## Memory Management
|
|
152
|
+
|
|
153
|
+
Durable state files the agent can read and update:
|
|
154
|
+
|
|
155
|
+
- `~/MEMORY.md` — stable facts, preferences, project context (edit existing entries, don't append duplicates)
|
|
156
|
+
- `~/daily/YYYY-MM-DD/front-page.md` — today's work log, decisions, open loops
|
|
157
|
+
- `~/daily/YYYY-MM-DD/articles/` — detailed write-ups for durable topics
|
|
158
|
+
|
|
159
|
+
## AWS CLI
|
|
160
|
+
|
|
161
|
+
Full AWS access via instance role (us-east-1). No credentials needed.
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
aws sts get-caller-identity
|
|
165
|
+
aws s3 ls
|
|
166
|
+
aws logs tail /aws/lambda/<name> --since 1h
|
|
167
|
+
aws cloudformation describe-stacks --stack-name <name>
|
|
168
|
+
```
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ipc/client.ts — CLI client to send messages to the running gateway
|
|
3
|
+
*
|
|
4
|
+
* Connects to ~/.roundhouse/gateway.sock, sends JSON, reads response, closes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createConnection } from "node:net";
|
|
8
|
+
import { SOCKET_PATH } from "./server";
|
|
9
|
+
import type { IpcRequest, IpcResponse } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Send a request to the running gateway via IPC.
|
|
13
|
+
* Returns the response, or throws if gateway is unreachable.
|
|
14
|
+
*/
|
|
15
|
+
export async function sendIpc(request: IpcRequest, opts?: { timeoutMs?: number; socketPath?: string }): Promise<IpcResponse> {
|
|
16
|
+
const { timeoutMs = 5000, socketPath = SOCKET_PATH } = opts ?? {};
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const conn = createConnection(socketPath);
|
|
19
|
+
let data = "";
|
|
20
|
+
let done = false;
|
|
21
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
22
|
+
|
|
23
|
+
const finish = (result: IpcResponse | Error) => {
|
|
24
|
+
if (done) return;
|
|
25
|
+
done = true;
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
conn.destroy();
|
|
28
|
+
if (result instanceof Error) reject(result);
|
|
29
|
+
else resolve(result);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
conn.on("connect", () => {
|
|
33
|
+
conn.write(JSON.stringify(request) + "\n");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
conn.on("data", (chunk) => {
|
|
37
|
+
data += chunk.toString();
|
|
38
|
+
const newlineIdx = data.indexOf("\n");
|
|
39
|
+
if (newlineIdx === -1) return;
|
|
40
|
+
try {
|
|
41
|
+
finish(JSON.parse(data.slice(0, newlineIdx)));
|
|
42
|
+
} catch {
|
|
43
|
+
finish(new Error("Invalid response from gateway"));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
conn.on("error", (err: NodeJS.ErrnoException) => {
|
|
48
|
+
if (err.code === "ENOENT" || err.code === "ECONNREFUSED") {
|
|
49
|
+
finish(new Error("Gateway is not running. Start with: roundhouse start"));
|
|
50
|
+
} else {
|
|
51
|
+
finish(err);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
conn.on("close", () => finish(new Error("Connection closed without response")));
|
|
56
|
+
|
|
57
|
+
timer = setTimeout(() => finish(new Error("IPC timeout")), timeoutMs);
|
|
58
|
+
});
|
|
59
|
+
}
|
package/src/ipc/index.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ipc/server.ts — Unix socket server for gateway IPC
|
|
3
|
+
*
|
|
4
|
+
* Listens on ~/.roundhouse/gateway.sock.
|
|
5
|
+
* Protocol: newline-delimited JSON (one request, one response, close).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createServer, type Server } from "node:net";
|
|
9
|
+
import { unlinkSync, chmodSync, existsSync } from "node:fs";
|
|
10
|
+
import { ROUNDHOUSE_DIR } from "../config";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import type { IpcRequest, IpcResponse } from "./types";
|
|
13
|
+
|
|
14
|
+
export const SOCKET_PATH = resolve(ROUNDHOUSE_DIR, "gateway.sock");
|
|
15
|
+
|
|
16
|
+
export type IpcHandler = (request: IpcRequest) => Promise<IpcResponse>;
|
|
17
|
+
|
|
18
|
+
export class IpcServer {
|
|
19
|
+
private server: Server | null = null;
|
|
20
|
+
private socketPath: string;
|
|
21
|
+
|
|
22
|
+
constructor(private handler: IpcHandler, socketPath?: string) {
|
|
23
|
+
this.socketPath = socketPath ?? SOCKET_PATH;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getSocketPath(): string { return this.socketPath; }
|
|
27
|
+
|
|
28
|
+
async start(): Promise<void> {
|
|
29
|
+
// Remove stale socket if present (TOCTOU race acknowledged — no fix without flock)
|
|
30
|
+
if (existsSync(this.socketPath)) {
|
|
31
|
+
const { createConnection } = await import("node:net");
|
|
32
|
+
const alive = await new Promise<boolean>((res) => {
|
|
33
|
+
const conn = createConnection(this.socketPath);
|
|
34
|
+
const timer = setTimeout(() => { conn.destroy(); res(false); }, 500);
|
|
35
|
+
conn.on("connect", () => { clearTimeout(timer); conn.end(); res(true); });
|
|
36
|
+
conn.on("error", () => { clearTimeout(timer); res(false); });
|
|
37
|
+
});
|
|
38
|
+
if (alive) {
|
|
39
|
+
throw new Error("Another gateway is already running (socket in use)");
|
|
40
|
+
}
|
|
41
|
+
try { unlinkSync(this.socketPath); } catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.server = createServer((conn) => {
|
|
45
|
+
let data = "";
|
|
46
|
+
let handled = false;
|
|
47
|
+
const MAX_BYTES = 64 * 1024; // 64KB
|
|
48
|
+
|
|
49
|
+
conn.on("data", (chunk) => {
|
|
50
|
+
if (handled) return;
|
|
51
|
+
data += chunk.toString();
|
|
52
|
+
if (data.length > MAX_BYTES) {
|
|
53
|
+
conn.destroy();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const newlineIdx = data.indexOf("\n");
|
|
57
|
+
if (newlineIdx === -1) return;
|
|
58
|
+
|
|
59
|
+
handled = true;
|
|
60
|
+
const line = data.slice(0, newlineIdx);
|
|
61
|
+
|
|
62
|
+
let request: IpcRequest;
|
|
63
|
+
try {
|
|
64
|
+
request = JSON.parse(line);
|
|
65
|
+
} catch {
|
|
66
|
+
conn.end(JSON.stringify({ ok: false, error: "Invalid JSON" }) + "\n");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.handler(request).then((response) => {
|
|
71
|
+
conn.end(JSON.stringify(response) + "\n");
|
|
72
|
+
}).catch((err) => {
|
|
73
|
+
conn.end(JSON.stringify({ ok: false, error: err.message }) + "\n");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Timeout connections that send nothing
|
|
78
|
+
conn.setTimeout(5000, () => conn.destroy());
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await new Promise<void>((resolve, reject) => {
|
|
82
|
+
const onError = (err: Error) => reject(err);
|
|
83
|
+
this.server!.on("error", onError);
|
|
84
|
+
this.server!.listen(this.socketPath, () => {
|
|
85
|
+
this.server!.removeListener("error", onError);
|
|
86
|
+
this.server!.on("error", (e) => console.error("[roundhouse] IPC server error:", e.message));
|
|
87
|
+
// Restrict permissions: owner only
|
|
88
|
+
chmodSync(this.socketPath, 0o600);
|
|
89
|
+
console.log(`[roundhouse] IPC listening on ${this.socketPath}`);
|
|
90
|
+
resolve();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
stop(): void {
|
|
96
|
+
if (this.server) {
|
|
97
|
+
this.server.close();
|
|
98
|
+
this.server = null;
|
|
99
|
+
}
|
|
100
|
+
try { unlinkSync(this.socketPath); } catch {}
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/ipc/types.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ipc/types.ts — Shared types for the IPC protocol
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Messages the CLI can send to the gateway */
|
|
6
|
+
export type IpcRequest =
|
|
7
|
+
| { type: "notify"; text: string; session?: string }
|
|
8
|
+
| { type: "ping" };
|
|
9
|
+
|
|
10
|
+
/** Responses the gateway sends back */
|
|
11
|
+
export type IpcResponse =
|
|
12
|
+
| { ok: true }
|
|
13
|
+
| { ok: false; error: string };
|
|
@@ -226,6 +226,7 @@ export function provisionBundle(opts: ProvisionOpts = {}): void {
|
|
|
226
226
|
provisionMcporterConfig(opts);
|
|
227
227
|
provisionExtensionFiles(opts);
|
|
228
228
|
provisionExtensions(opts);
|
|
229
|
+
provisionWorkspaceFiles(opts);
|
|
229
230
|
}
|
|
230
231
|
|
|
231
232
|
/**
|
|
@@ -306,3 +307,33 @@ export function provisionExtensions(opts: ProvisionOpts = {}): void {
|
|
|
306
307
|
log.warn(`extensions provisioning failed: ${err.message}`);
|
|
307
308
|
}
|
|
308
309
|
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Copy workspace files (tools.md, etc.) to ~/.roundhouse/ if not already present.
|
|
313
|
+
* Never overwrites — user's customized version always wins.
|
|
314
|
+
*/
|
|
315
|
+
export function provisionWorkspaceFiles(opts: ProvisionOpts = {}): void {
|
|
316
|
+
const { force = false, log = consoleLog } = opts;
|
|
317
|
+
const roundhouseDir = resolve(homedir(), ".roundhouse");
|
|
318
|
+
const bundledDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "gateway");
|
|
319
|
+
|
|
320
|
+
// Files to provision: [bundled filename, target filename]
|
|
321
|
+
const files: [string, string][] = [
|
|
322
|
+
["tools.md", "tools.md"],
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
mkdirSync(roundhouseDir, { recursive: true });
|
|
327
|
+
|
|
328
|
+
for (const [src, dest] of files) {
|
|
329
|
+
const srcPath = resolve(bundledDir, src);
|
|
330
|
+
const destPath = resolve(roundhouseDir, dest);
|
|
331
|
+
if (!existsSync(srcPath)) continue;
|
|
332
|
+
if (existsSync(destPath) && !force) continue; // never overwrite unless forced
|
|
333
|
+
copyFileSync(srcPath, destPath);
|
|
334
|
+
log.ok(`${dest} provisioned to ~/.roundhouse/`);
|
|
335
|
+
}
|
|
336
|
+
} catch (err: any) {
|
|
337
|
+
log.warn(`workspace files provisioning failed: ${err.message}`);
|
|
338
|
+
}
|
|
339
|
+
}
|