@frankleeeee/flowy-runner 1.0.0

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/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # Flowy Runner
2
+
3
+ `@frankleeeee/flowy-runner` connects a machine to Flowy and executes assigned tasks with supported local AI CLIs.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 23+
8
+ - one or more supported CLIs installed:
9
+ - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) via `claude`
10
+ - [Codex](https://github.com/openai/codex) via `codex`
11
+
12
+ ## Install
13
+
14
+ ### From this repository
15
+
16
+ ```bash
17
+ npm install
18
+ npm run build --workspace=runner
19
+ ```
20
+
21
+ ### As the published npm package
22
+
23
+ ```bash
24
+ npm install -g @frankleeeee/flowy-runner
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ Run the runner with:
30
+
31
+ ```bash
32
+ flowy-runner --name <name> --url <hub-url> [options]
33
+ ```
34
+
35
+ Example:
36
+
37
+ ```bash
38
+ flowy-runner --name office-mac --url http://localhost:3001 --secret YOUR_SECRET
39
+ ```
40
+
41
+ ## Flags
42
+
43
+ ### Required
44
+
45
+ | Flag | Description |
46
+ |------|-------------|
47
+ | `--name <name>` | Unique runner name |
48
+ | `--url <url>` | Flowy backend URL |
49
+
50
+ ### Optional
51
+
52
+ | Flag | Description |
53
+ |------|-------------|
54
+ | `--poll-interval <seconds>` | Poll interval in seconds. Default: `5` |
55
+ | `--token <token>` | Reuse an existing runner token |
56
+ | `--secret <secret>` | Registration secret if the server requires one |
57
+ | `--device <info>` | Device info string. Default: hostname |
58
+
59
+ ## Behavior
60
+
61
+ When the runner starts it will:
62
+
63
+ 1. detect available CLIs on the machine
64
+ 2. register with Flowy if it does not already have a saved token
65
+ 3. send heartbeats so it appears online
66
+ 4. poll for assigned tasks
67
+ 5. execute tasks using the selected provider
68
+ 6. stream the full CLI output back to Flowy
69
+
70
+ Supported provider detection:
71
+
72
+ - `claude` -> `claude-code`
73
+ - `codex` -> `codex`
74
+
75
+ ## Execution
76
+
77
+ Current task execution commands:
78
+
79
+ | Provider | Command shape |
80
+ |----------|---------------|
81
+ | `claude-code` | `claude -p "<task prompt>" --tools all` |
82
+ | `codex` | `codex exec "<task prompt>" --sandbox workspace-write --color never` |
83
+
84
+ ## Token persistence
85
+
86
+ Runner tokens are stored at:
87
+
88
+ ```text
89
+ ~/.config/flowy/runner-<name>.json
90
+ ```
91
+
92
+ If a runner token is rejected with `401`, the runner deletes the local token file so the next launch can register cleanly again.
93
+
94
+ ## Development
95
+
96
+ From the `runner/` directory:
97
+
98
+ ```bash
99
+ npm install
100
+ npm run dev -- --name my-runner --url http://localhost:3001
101
+ ```
102
+
103
+ Or from the repository root:
104
+
105
+ ```bash
106
+ npm run dev --workspace=runner -- --name my-runner --url http://localhost:3001
107
+ ```
108
+
109
+ ## Build
110
+
111
+ From the `runner/` directory:
112
+
113
+ ```bash
114
+ npm run build
115
+ ```
116
+
117
+ Or from the repository root:
118
+
119
+ ```bash
120
+ npm run build --workspace=runner
121
+ ```
122
+
123
+ ## Publish
124
+
125
+ This repo includes a GitHub Actions workflow for publishing `@frankleeeee/flowy-runner`.
126
+
127
+ Trigger either of these:
128
+
129
+ - push a tag like `flowy-runner-v1.2.3`
130
+ - run the `Publish Flowy Runner` workflow manually with a version
package/dist/api.js ADDED
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.RunnerApi = void 0;
7
+ const axios_1 = __importDefault(require("axios"));
8
+ class RunnerApi {
9
+ client;
10
+ token = '';
11
+ constructor(baseUrl) {
12
+ this.client = axios_1.default.create({ baseURL: baseUrl + '/api' });
13
+ }
14
+ setToken(token) {
15
+ this.token = token;
16
+ this.client.defaults.headers.common['Authorization'] = `Bearer ${token}`;
17
+ }
18
+ async register(name, aiProviders, deviceInfo, secret) {
19
+ const { data } = await this.client.post('/runners/register', {
20
+ name, aiProviders, deviceInfo, secret,
21
+ });
22
+ return data;
23
+ }
24
+ async heartbeat(aiProviders, lastCliScanAt) {
25
+ const { data } = await this.client.post('/runners/heartbeat', { aiProviders, lastCliScanAt });
26
+ return data;
27
+ }
28
+ async poll() {
29
+ const resp = await this.client.get('/runners/poll');
30
+ if (resp.status === 204)
31
+ return null;
32
+ return resp.data;
33
+ }
34
+ async pickTask(taskId) {
35
+ await this.client.post(`/runners/tasks/${taskId}/pick`);
36
+ }
37
+ async sendOutput(taskId, data) {
38
+ await this.client.post(`/runners/tasks/${taskId}/output`, { data });
39
+ }
40
+ async completeTask(taskId, success, data) {
41
+ await this.client.post(`/runners/tasks/${taskId}/complete`, { success, data });
42
+ }
43
+ }
44
+ exports.RunnerApi = RunnerApi;
package/dist/cli.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const config_1 = require("./config");
5
+ const daemon_1 = require("./daemon");
6
+ const config = (0, config_1.parseArgs)(process.argv);
7
+ (0, daemon_1.startDaemon)(config).catch((err) => {
8
+ console.error('Fatal error:', err.message ?? err);
9
+ process.exit(1);
10
+ });
package/dist/config.js ADDED
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseArgs = parseArgs;
7
+ exports.detectAvailableProviders = detectAvailableProviders;
8
+ exports.saveToken = saveToken;
9
+ exports.deleteToken = deleteToken;
10
+ const os_1 = __importDefault(require("os"));
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const child_process_1 = require("child_process");
14
+ const CONFIG_DIR = path_1.default.join(os_1.default.homedir(), '.config', 'flowy');
15
+ const SUPPORTED_PROVIDERS = [
16
+ { id: 'claude-code', command: 'claude' },
17
+ { id: 'codex', command: 'codex' },
18
+ ];
19
+ function parseArgs(argv) {
20
+ const args = argv.slice(2);
21
+ let name = '';
22
+ let url = '';
23
+ let pollInterval = 5;
24
+ let token;
25
+ let secret;
26
+ let device = os_1.default.hostname();
27
+ for (let i = 0; i < args.length; i++) {
28
+ switch (args[i]) {
29
+ case '--name':
30
+ name = args[++i] ?? '';
31
+ break;
32
+ case '--url':
33
+ url = args[++i] ?? '';
34
+ break;
35
+ case '--poll-interval':
36
+ pollInterval = parseInt(args[++i] ?? '5', 10);
37
+ break;
38
+ case '--token':
39
+ token = args[++i];
40
+ break;
41
+ case '--secret':
42
+ secret = args[++i];
43
+ break;
44
+ case '--device':
45
+ device = args[++i] ?? os_1.default.hostname();
46
+ break;
47
+ }
48
+ }
49
+ if (!name || !url) {
50
+ console.error('Usage: flowy-runner --name <name> --url <backend-url> [options]');
51
+ console.error('');
52
+ console.error('Required:');
53
+ console.error(' --name Runner name');
54
+ console.error(' --url Backend URL (e.g. http://localhost:3001)');
55
+ console.error('');
56
+ console.error('Options:');
57
+ console.error(' --poll-interval Poll interval in seconds (default: 5)');
58
+ console.error(' --token Existing runner token (skip registration)');
59
+ console.error(' --secret Registration secret (required if server has one configured)');
60
+ console.error(' --device Device info (default: hostname)');
61
+ process.exit(1);
62
+ }
63
+ // If no token provided, try to load from saved config
64
+ if (!token) {
65
+ token = loadToken(name);
66
+ }
67
+ const providers = detectAvailableProviders();
68
+ if (providers.length === 0) {
69
+ console.error('No supported AI CLIs were detected on this machine.');
70
+ console.error('Install one of: claude, codex');
71
+ process.exit(1);
72
+ }
73
+ return {
74
+ name,
75
+ url: url.replace(/\/$/, ''),
76
+ providers,
77
+ lastCliScanAt: new Date().toISOString(),
78
+ pollInterval,
79
+ token,
80
+ secret,
81
+ device,
82
+ };
83
+ }
84
+ function detectAvailableProviders() {
85
+ return SUPPORTED_PROVIDERS
86
+ .filter((provider) => isCommandAvailable(provider.command))
87
+ .map((provider) => provider.id);
88
+ }
89
+ function isCommandAvailable(command) {
90
+ const checker = process.platform === 'win32' ? 'where' : 'which';
91
+ const result = (0, child_process_1.spawnSync)(checker, [command], { stdio: 'ignore' });
92
+ return result.status === 0;
93
+ }
94
+ function loadToken(name) {
95
+ const file = path_1.default.join(CONFIG_DIR, `runner-${name}.json`);
96
+ try {
97
+ const data = JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
98
+ return data.token;
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ }
104
+ function saveToken(name, id, token) {
105
+ fs_1.default.mkdirSync(CONFIG_DIR, { recursive: true });
106
+ const file = path_1.default.join(CONFIG_DIR, `runner-${name}.json`);
107
+ fs_1.default.writeFileSync(file, JSON.stringify({ id, token }, null, 2));
108
+ }
109
+ function deleteToken(name) {
110
+ const file = path_1.default.join(CONFIG_DIR, `runner-${name}.json`);
111
+ try {
112
+ fs_1.default.unlinkSync(file);
113
+ }
114
+ catch {
115
+ // Ignore missing/unreadable token files.
116
+ }
117
+ }
package/dist/daemon.js ADDED
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.startDaemon = startDaemon;
4
+ const api_1 = require("./api");
5
+ const config_1 = require("./config");
6
+ const executor_1 = require("./executor");
7
+ async function startDaemon(config) {
8
+ const api = new api_1.RunnerApi(config.url);
9
+ let availableProviders = [...config.providers];
10
+ let lastCliScanAt = config.lastCliScanAt;
11
+ // ── Registration ────────────────────────────────────────────────────────
12
+ if (config.token) {
13
+ console.log(`Using existing token for runner "${config.name}"`);
14
+ api.setToken(config.token);
15
+ }
16
+ else {
17
+ console.log(`Registering runner "${config.name}" at ${config.url}...`);
18
+ const { id, token } = await api.register(config.name, availableProviders, config.device, config.secret);
19
+ api.setToken(token);
20
+ (0, config_1.saveToken)(config.name, id, token);
21
+ console.log(`Registered! Runner ID: ${id}`);
22
+ console.log(`Token saved to ~/.config/flowy/runner-${config.name}.json`);
23
+ }
24
+ let executing = false;
25
+ let killCurrent = null;
26
+ const handleInvalidToken = () => {
27
+ (0, config_1.deleteToken)(config.name);
28
+ console.error('Runner token rejected (401). Deleted local runner token; restart to register again.');
29
+ process.exit(1);
30
+ };
31
+ // ── Heartbeat ───────────────────────────────────────────────────────────
32
+ const heartbeat = async () => {
33
+ try {
34
+ const response = await api.heartbeat(availableProviders, lastCliScanAt);
35
+ if (response.refreshCli) {
36
+ availableProviders = (0, config_1.detectAvailableProviders)();
37
+ lastCliScanAt = new Date().toISOString();
38
+ console.log(`Refreshing available CLIs: ${availableProviders.join(', ') || '(none)'}`);
39
+ await api.heartbeat(availableProviders, lastCliScanAt);
40
+ }
41
+ }
42
+ catch (err) {
43
+ const msg = err instanceof Error ? err.message : String(err);
44
+ if (msg.includes('401')) {
45
+ handleInvalidToken();
46
+ }
47
+ console.warn('Heartbeat failed:', msg);
48
+ }
49
+ };
50
+ // Send initial heartbeat, then every 30 seconds
51
+ await heartbeat();
52
+ const heartbeatInterval = setInterval(heartbeat, 30_000);
53
+ // ── Polling ─────────────────────────────────────────────────────────────
54
+ const poll = async () => {
55
+ if (executing)
56
+ return;
57
+ try {
58
+ const task = await api.poll();
59
+ if (!task)
60
+ return;
61
+ console.log(`\nPicked up task: ${task.task_key} - ${task.title}`);
62
+ console.log(` AI Provider: ${task.ai_provider}`);
63
+ console.log(` Description: ${(task.description || task.title).slice(0, 100)}...`);
64
+ await api.pickTask(task.id);
65
+ executing = true;
66
+ const { promise, kill } = (0, executor_1.executeTask)(task, async (chunk) => {
67
+ try {
68
+ await api.sendOutput(task.id, chunk);
69
+ }
70
+ catch (err) {
71
+ console.warn('Failed to send output chunk:', err instanceof Error ? err.message : err);
72
+ }
73
+ });
74
+ killCurrent = kill;
75
+ const result = await promise;
76
+ killCurrent = null;
77
+ executing = false;
78
+ console.log(`\nTask ${task.task_key} ${result.success ? 'completed' : 'failed'}`);
79
+ await api.completeTask(task.id, result.success, result.sendOnComplete ? result.output : '');
80
+ }
81
+ catch (err) {
82
+ executing = false;
83
+ killCurrent = null;
84
+ const msg = err instanceof Error ? err.message : String(err);
85
+ if (msg.includes('401')) {
86
+ handleInvalidToken();
87
+ }
88
+ console.error('Poll/execute error:', err instanceof Error ? err.message : err);
89
+ }
90
+ };
91
+ const pollInterval = setInterval(poll, config.pollInterval * 1000);
92
+ console.log(`\nRunner "${config.name}" is online and polling every ${config.pollInterval}s`);
93
+ console.log(`Providers: ${availableProviders.join(', ') || '(none)'}`);
94
+ console.log('Press Ctrl+C to stop.\n');
95
+ // ── Graceful Shutdown ───────────────────────────────────────────────────
96
+ const shutdown = async (signal) => {
97
+ console.log(`\nReceived ${signal}, shutting down...`);
98
+ clearInterval(heartbeatInterval);
99
+ clearInterval(pollInterval);
100
+ if (killCurrent) {
101
+ console.log('Killing running task...');
102
+ killCurrent();
103
+ }
104
+ // Give a moment for cleanup
105
+ setTimeout(() => process.exit(0), 500);
106
+ };
107
+ process.on('SIGINT', () => shutdown('SIGINT'));
108
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
109
+ }
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.executeTask = executeTask;
4
+ const child_process_1 = require("child_process");
5
+ /** Build the CLI command + args for a given AI provider. */
6
+ function buildCommand(aiProvider, prompt) {
7
+ switch (aiProvider) {
8
+ case 'claude-code':
9
+ return { cmd: 'claude', args: ['-p', prompt, "--tools", "all"], streamOutput: true };
10
+ case 'codex':
11
+ return {
12
+ cmd: 'codex',
13
+ args: ['exec', prompt, '--sandbox', 'workspace-write', '--color', 'never'],
14
+ streamOutput: true,
15
+ };
16
+ default:
17
+ throw new Error(`Unknown AI provider: ${aiProvider}`);
18
+ }
19
+ }
20
+ /**
21
+ * Execute a task using the specified AI CLI tool.
22
+ * Calls `onOutput` with buffered chunks every ~2 seconds.
23
+ * Returns when the process exits.
24
+ */
25
+ function executeTask(task, onOutput) {
26
+ const { cmd, args, streamOutput } = buildCommand(task.ai_provider, task.description || task.title);
27
+ let child;
28
+ let fullOutput = '';
29
+ let buffer = '';
30
+ let flushTimer;
31
+ const promise = new Promise((resolve) => {
32
+ console.log(` Spawning: ${cmd} ${args.join(' ')}`);
33
+ child = (0, child_process_1.spawn)(cmd, args, {
34
+ stdio: ['ignore', 'pipe', 'pipe'],
35
+ env: { ...process.env },
36
+ });
37
+ const flush = () => {
38
+ if (streamOutput && buffer.length > 0) {
39
+ onOutput(buffer);
40
+ buffer = '';
41
+ }
42
+ };
43
+ flushTimer = setInterval(flush, 2000);
44
+ child.stdout?.on('data', (data) => {
45
+ const text = data.toString();
46
+ fullOutput += text;
47
+ buffer += text;
48
+ });
49
+ child.stderr?.on('data', (data) => {
50
+ const text = data.toString();
51
+ fullOutput += text;
52
+ buffer += text;
53
+ });
54
+ child.on('error', (err) => {
55
+ clearInterval(flushTimer);
56
+ flush();
57
+ fullOutput += `\n[Error: ${err.message}]`;
58
+ resolve({ success: false, output: fullOutput, sendOnComplete: true });
59
+ });
60
+ child.on('close', (code) => {
61
+ clearInterval(flushTimer);
62
+ flush();
63
+ const success = code === 0;
64
+ resolve({ success, output: fullOutput, sendOnComplete: !streamOutput || !success });
65
+ });
66
+ });
67
+ const kill = () => {
68
+ clearInterval(flushTimer);
69
+ child?.kill('SIGTERM');
70
+ };
71
+ return { promise, kill };
72
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@frankleeeee/flowy-runner",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "bin": {
6
+ "flowy-runner": "./dist/cli.js"
7
+ },
8
+ "scripts": {
9
+ "dev": "ts-node-dev --respawn --transpile-only src/cli.ts --",
10
+ "build": "tsc",
11
+ "start": "node dist/cli.js"
12
+ },
13
+ "dependencies": {
14
+ "axios": "^1.6.8"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "engines": {
20
+ "node": ">=23"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^20.11.30",
24
+ "ts-node-dev": "^2.0.0",
25
+ "typescript": "^5.4.3"
26
+ }
27
+ }
package/src/api.ts ADDED
@@ -0,0 +1,46 @@
1
+ import axios, { AxiosInstance } from 'axios';
2
+ import { Task, RegisterResponse, HeartbeatResponse } from './types';
3
+
4
+ export class RunnerApi {
5
+ private client: AxiosInstance;
6
+ private token: string = '';
7
+
8
+ constructor(baseUrl: string) {
9
+ this.client = axios.create({ baseURL: baseUrl + '/api' });
10
+ }
11
+
12
+ setToken(token: string): void {
13
+ this.token = token;
14
+ this.client.defaults.headers.common['Authorization'] = `Bearer ${token}`;
15
+ }
16
+
17
+ async register(name: string, aiProviders: string[], deviceInfo: string, secret?: string): Promise<RegisterResponse> {
18
+ const { data } = await this.client.post<RegisterResponse>('/runners/register', {
19
+ name, aiProviders, deviceInfo, secret,
20
+ });
21
+ return data;
22
+ }
23
+
24
+ async heartbeat(aiProviders: string[], lastCliScanAt?: string): Promise<HeartbeatResponse> {
25
+ const { data } = await this.client.post<HeartbeatResponse>('/runners/heartbeat', { aiProviders, lastCliScanAt });
26
+ return data;
27
+ }
28
+
29
+ async poll(): Promise<Task | null> {
30
+ const resp = await this.client.get('/runners/poll');
31
+ if (resp.status === 204) return null;
32
+ return resp.data;
33
+ }
34
+
35
+ async pickTask(taskId: string): Promise<void> {
36
+ await this.client.post(`/runners/tasks/${taskId}/pick`);
37
+ }
38
+
39
+ async sendOutput(taskId: string, data: string): Promise<void> {
40
+ await this.client.post(`/runners/tasks/${taskId}/output`, { data });
41
+ }
42
+
43
+ async completeTask(taskId: string, success: boolean, data?: string): Promise<void> {
44
+ await this.client.post(`/runners/tasks/${taskId}/complete`, { success, data });
45
+ }
46
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseArgs } from './config';
4
+ import { startDaemon } from './daemon';
5
+
6
+ const config = parseArgs(process.argv);
7
+
8
+ startDaemon(config).catch((err) => {
9
+ console.error('Fatal error:', err.message ?? err);
10
+ process.exit(1);
11
+ });
package/src/config.ts ADDED
@@ -0,0 +1,107 @@
1
+ import os from 'os';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { spawnSync } from 'child_process';
5
+ import { RunnerConfig } from './types';
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'flowy');
8
+ const SUPPORTED_PROVIDERS = [
9
+ { id: 'claude-code', command: 'claude' },
10
+ { id: 'codex', command: 'codex' },
11
+ ] as const;
12
+
13
+ export function parseArgs(argv: string[]): RunnerConfig {
14
+ const args = argv.slice(2);
15
+ let name = '';
16
+ let url = '';
17
+ let pollInterval = 5;
18
+ let token: string | undefined;
19
+ let secret: string | undefined;
20
+ let device = os.hostname();
21
+
22
+ for (let i = 0; i < args.length; i++) {
23
+ switch (args[i]) {
24
+ case '--name': name = args[++i] ?? ''; break;
25
+ case '--url': url = args[++i] ?? ''; break;
26
+ case '--poll-interval': pollInterval = parseInt(args[++i] ?? '5', 10); break;
27
+ case '--token': token = args[++i]; break;
28
+ case '--secret': secret = args[++i]; break;
29
+ case '--device': device = args[++i] ?? os.hostname(); break;
30
+ }
31
+ }
32
+
33
+ if (!name || !url) {
34
+ console.error('Usage: flowy-runner --name <name> --url <backend-url> [options]');
35
+ console.error('');
36
+ console.error('Required:');
37
+ console.error(' --name Runner name');
38
+ console.error(' --url Backend URL (e.g. http://localhost:3001)');
39
+ console.error('');
40
+ console.error('Options:');
41
+ console.error(' --poll-interval Poll interval in seconds (default: 5)');
42
+ console.error(' --token Existing runner token (skip registration)');
43
+ console.error(' --secret Registration secret (required if server has one configured)');
44
+ console.error(' --device Device info (default: hostname)');
45
+ process.exit(1);
46
+ }
47
+
48
+ // If no token provided, try to load from saved config
49
+ if (!token) {
50
+ token = loadToken(name);
51
+ }
52
+
53
+ const providers = detectAvailableProviders();
54
+ if (providers.length === 0) {
55
+ console.error('No supported AI CLIs were detected on this machine.');
56
+ console.error('Install one of: claude, codex');
57
+ process.exit(1);
58
+ }
59
+
60
+ return {
61
+ name,
62
+ url: url.replace(/\/$/, ''),
63
+ providers,
64
+ lastCliScanAt: new Date().toISOString(),
65
+ pollInterval,
66
+ token,
67
+ secret,
68
+ device,
69
+ };
70
+ }
71
+
72
+ export function detectAvailableProviders(): string[] {
73
+ return SUPPORTED_PROVIDERS
74
+ .filter((provider) => isCommandAvailable(provider.command))
75
+ .map((provider) => provider.id);
76
+ }
77
+
78
+ function isCommandAvailable(command: string): boolean {
79
+ const checker = process.platform === 'win32' ? 'where' : 'which';
80
+ const result = spawnSync(checker, [command], { stdio: 'ignore' });
81
+ return result.status === 0;
82
+ }
83
+
84
+ function loadToken(name: string): string | undefined {
85
+ const file = path.join(CONFIG_DIR, `runner-${name}.json`);
86
+ try {
87
+ const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
88
+ return data.token;
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ export function saveToken(name: string, id: string, token: string): void {
95
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
96
+ const file = path.join(CONFIG_DIR, `runner-${name}.json`);
97
+ fs.writeFileSync(file, JSON.stringify({ id, token }, null, 2));
98
+ }
99
+
100
+ export function deleteToken(name: string): void {
101
+ const file = path.join(CONFIG_DIR, `runner-${name}.json`);
102
+ try {
103
+ fs.unlinkSync(file);
104
+ } catch {
105
+ // Ignore missing/unreadable token files.
106
+ }
107
+ }
package/src/daemon.ts ADDED
@@ -0,0 +1,122 @@
1
+ import { RunnerConfig } from './types';
2
+ import { RunnerApi } from './api';
3
+ import { deleteToken, detectAvailableProviders, saveToken } from './config';
4
+ import { executeTask } from './executor';
5
+
6
+ export async function startDaemon(config: RunnerConfig): Promise<void> {
7
+ const api = new RunnerApi(config.url);
8
+ let availableProviders = [...config.providers];
9
+ let lastCliScanAt = config.lastCliScanAt;
10
+
11
+ // ── Registration ────────────────────────────────────────────────────────
12
+ if (config.token) {
13
+ console.log(`Using existing token for runner "${config.name}"`);
14
+ api.setToken(config.token);
15
+ } else {
16
+ console.log(`Registering runner "${config.name}" at ${config.url}...`);
17
+ const { id, token } = await api.register(config.name, availableProviders, config.device, config.secret);
18
+ api.setToken(token);
19
+ saveToken(config.name, id, token);
20
+ console.log(`Registered! Runner ID: ${id}`);
21
+ console.log(`Token saved to ~/.config/flowy/runner-${config.name}.json`);
22
+ }
23
+
24
+ let executing = false;
25
+ let killCurrent: (() => void) | null = null;
26
+
27
+ const handleInvalidToken = () => {
28
+ deleteToken(config.name);
29
+ console.error('Runner token rejected (401). Deleted local runner token; restart to register again.');
30
+ process.exit(1);
31
+ };
32
+
33
+ // ── Heartbeat ───────────────────────────────────────────────────────────
34
+ const heartbeat = async () => {
35
+ try {
36
+ const response = await api.heartbeat(availableProviders, lastCliScanAt);
37
+ if (response.refreshCli) {
38
+ availableProviders = detectAvailableProviders();
39
+ lastCliScanAt = new Date().toISOString();
40
+ console.log(`Refreshing available CLIs: ${availableProviders.join(', ') || '(none)'}`);
41
+ await api.heartbeat(availableProviders, lastCliScanAt);
42
+ }
43
+ } catch (err: unknown) {
44
+ const msg = err instanceof Error ? err.message : String(err);
45
+ if (msg.includes('401')) {
46
+ handleInvalidToken();
47
+ }
48
+ console.warn('Heartbeat failed:', msg);
49
+ }
50
+ };
51
+
52
+ // Send initial heartbeat, then every 30 seconds
53
+ await heartbeat();
54
+ const heartbeatInterval = setInterval(heartbeat, 30_000);
55
+
56
+ // ── Polling ─────────────────────────────────────────────────────────────
57
+ const poll = async () => {
58
+ if (executing) return;
59
+
60
+ try {
61
+ const task = await api.poll();
62
+ if (!task) return;
63
+
64
+ console.log(`\nPicked up task: ${task.task_key} - ${task.title}`);
65
+ console.log(` AI Provider: ${task.ai_provider}`);
66
+ console.log(` Description: ${(task.description || task.title).slice(0, 100)}...`);
67
+
68
+ await api.pickTask(task.id);
69
+ executing = true;
70
+
71
+ const { promise, kill } = executeTask(task, async (chunk) => {
72
+ try {
73
+ await api.sendOutput(task.id, chunk);
74
+ } catch (err) {
75
+ console.warn('Failed to send output chunk:', err instanceof Error ? err.message : err);
76
+ }
77
+ });
78
+
79
+ killCurrent = kill;
80
+
81
+ const result = await promise;
82
+ killCurrent = null;
83
+ executing = false;
84
+
85
+ console.log(`\nTask ${task.task_key} ${result.success ? 'completed' : 'failed'}`);
86
+ await api.completeTask(task.id, result.success, result.sendOnComplete ? result.output : '');
87
+
88
+ } catch (err) {
89
+ executing = false;
90
+ killCurrent = null;
91
+ const msg = err instanceof Error ? err.message : String(err);
92
+ if (msg.includes('401')) {
93
+ handleInvalidToken();
94
+ }
95
+ console.error('Poll/execute error:', err instanceof Error ? err.message : err);
96
+ }
97
+ };
98
+
99
+ const pollInterval = setInterval(poll, config.pollInterval * 1000);
100
+
101
+ console.log(`\nRunner "${config.name}" is online and polling every ${config.pollInterval}s`);
102
+ console.log(`Providers: ${availableProviders.join(', ') || '(none)'}`);
103
+ console.log('Press Ctrl+C to stop.\n');
104
+
105
+ // ── Graceful Shutdown ───────────────────────────────────────────────────
106
+ const shutdown = async (signal: string) => {
107
+ console.log(`\nReceived ${signal}, shutting down...`);
108
+ clearInterval(heartbeatInterval);
109
+ clearInterval(pollInterval);
110
+
111
+ if (killCurrent) {
112
+ console.log('Killing running task...');
113
+ killCurrent();
114
+ }
115
+
116
+ // Give a moment for cleanup
117
+ setTimeout(() => process.exit(0), 500);
118
+ };
119
+
120
+ process.on('SIGINT', () => shutdown('SIGINT'));
121
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
122
+ }
@@ -0,0 +1,96 @@
1
+ import { spawn, ChildProcess } from 'child_process';
2
+ import { Task } from './types';
3
+
4
+ export interface ExecutionResult {
5
+ success: boolean;
6
+ output: string;
7
+ sendOnComplete: boolean;
8
+ }
9
+
10
+ /** Build the CLI command + args for a given AI provider. */
11
+ function buildCommand(aiProvider: string, prompt: string): {
12
+ cmd: string;
13
+ args: string[];
14
+ streamOutput: boolean;
15
+ } {
16
+ switch (aiProvider) {
17
+ case 'claude-code':
18
+ return { cmd: 'claude', args: ['-p', prompt, "--tools", "all"], streamOutput: true };
19
+ case 'codex':
20
+ return {
21
+ cmd: 'codex',
22
+ args: ['exec', prompt, '--sandbox', 'workspace-write', '--color', 'never'],
23
+ streamOutput: true,
24
+ };
25
+ default:
26
+ throw new Error(`Unknown AI provider: ${aiProvider}`);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Execute a task using the specified AI CLI tool.
32
+ * Calls `onOutput` with buffered chunks every ~2 seconds.
33
+ * Returns when the process exits.
34
+ */
35
+ export function executeTask(
36
+ task: Task,
37
+ onOutput: (chunk: string) => void,
38
+ ): { promise: Promise<ExecutionResult>; kill: () => void } {
39
+ const { cmd, args, streamOutput } = buildCommand(task.ai_provider!, task.description || task.title);
40
+
41
+ let child: ChildProcess;
42
+ let fullOutput = '';
43
+ let buffer = '';
44
+ let flushTimer: ReturnType<typeof setInterval>;
45
+
46
+ const promise = new Promise<ExecutionResult>((resolve) => {
47
+ console.log(` Spawning: ${cmd} ${args.join(' ')}`);
48
+
49
+ child = spawn(cmd, args, {
50
+ stdio: ['ignore', 'pipe', 'pipe'],
51
+ env: { ...process.env },
52
+ });
53
+
54
+ const flush = () => {
55
+ if (streamOutput && buffer.length > 0) {
56
+ onOutput(buffer);
57
+ buffer = '';
58
+ }
59
+ };
60
+
61
+ flushTimer = setInterval(flush, 2000);
62
+
63
+ child.stdout?.on('data', (data: Buffer) => {
64
+ const text = data.toString();
65
+ fullOutput += text;
66
+ buffer += text;
67
+ });
68
+
69
+ child.stderr?.on('data', (data: Buffer) => {
70
+ const text = data.toString();
71
+ fullOutput += text;
72
+ buffer += text;
73
+ });
74
+
75
+ child.on('error', (err) => {
76
+ clearInterval(flushTimer);
77
+ flush();
78
+ fullOutput += `\n[Error: ${err.message}]`;
79
+ resolve({ success: false, output: fullOutput, sendOnComplete: true });
80
+ });
81
+
82
+ child.on('close', (code) => {
83
+ clearInterval(flushTimer);
84
+ flush();
85
+ const success = code === 0;
86
+ resolve({ success, output: fullOutput, sendOnComplete: !streamOutput || !success });
87
+ });
88
+ });
89
+
90
+ const kill = () => {
91
+ clearInterval(flushTimer!);
92
+ child?.kill('SIGTERM');
93
+ };
94
+
95
+ return { promise, kill };
96
+ }
package/src/types.ts ADDED
@@ -0,0 +1,40 @@
1
+ export interface RunnerConfig {
2
+ name: string;
3
+ url: string;
4
+ providers: string[];
5
+ lastCliScanAt: string;
6
+ pollInterval: number; // seconds
7
+ token?: string;
8
+ secret?: string;
9
+ device: string;
10
+ }
11
+
12
+ export interface Task {
13
+ id: string;
14
+ project_id: string;
15
+ task_number: number;
16
+ task_key: string;
17
+ title: string;
18
+ description: string;
19
+ status: string;
20
+ priority: string;
21
+ runner_id: string | null;
22
+ ai_provider: string | null;
23
+ labels: string;
24
+ output: string | null;
25
+ started_at: string | null;
26
+ completed_at: string | null;
27
+ created_at: string;
28
+ updated_at: string;
29
+ }
30
+
31
+ export interface RegisterResponse {
32
+ id: string;
33
+ token: string;
34
+ }
35
+
36
+ export interface HeartbeatResponse {
37
+ ok: boolean;
38
+ status: string;
39
+ refreshCli?: boolean;
40
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "lib": ["ES2022"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "resolveJsonModule": true,
12
+ "forceConsistentCasingInFileNames": true
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist"]
16
+ }