@frankleeeee/flowy-runner 1.0.0 → 1.0.1
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 +68 -78
- package/dist/cli.js +0 -0
- package/dist/config.js +8 -5
- package/dist/daemon.js +1 -1
- package/dist/executor.js +3 -0
- package/package.json +14 -9
- package/src/api.ts +0 -46
- package/src/cli.ts +0 -11
- package/src/config.ts +0 -107
- package/src/daemon.ts +0 -122
- package/src/executor.ts +0 -96
- package/src/types.ts +0 -40
- package/tsconfig.json +0 -16
package/README.md
CHANGED
|
@@ -1,130 +1,120 @@
|
|
|
1
1
|
# Flowy Runner
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A daemon that connects to the Flowy hub and executes tasks using AI CLI tools on your local machine.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Prerequisites
|
|
6
6
|
|
|
7
|
-
- Node.js
|
|
8
|
-
-
|
|
9
|
-
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
|
10
|
-
- [Codex](https://github.com/openai/codex)
|
|
7
|
+
- **Node.js v23+** (required for better-sqlite3 compatibility)
|
|
8
|
+
- One or more AI CLI tools installed:
|
|
9
|
+
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) — `claude`
|
|
10
|
+
- [Codex](https://github.com/openai/codex) — `codex`
|
|
11
|
+
- [Cursor Agent](https://docs.cursor.com/agent) — `agent`
|
|
11
12
|
|
|
12
|
-
##
|
|
13
|
+
## Build
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
From the repository root:
|
|
15
16
|
|
|
16
17
|
```bash
|
|
17
18
|
npm install
|
|
18
19
|
npm run build --workspace=runner
|
|
19
20
|
```
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
Or from the `runner/` directory:
|
|
22
23
|
|
|
23
24
|
```bash
|
|
24
|
-
npm install
|
|
25
|
+
npm install
|
|
26
|
+
npm run build
|
|
25
27
|
```
|
|
26
28
|
|
|
29
|
+
This compiles TypeScript to `dist/` via `tsc`.
|
|
30
|
+
|
|
27
31
|
## Usage
|
|
28
32
|
|
|
29
|
-
Run
|
|
33
|
+
Run via npm scripts:
|
|
30
34
|
|
|
31
35
|
```bash
|
|
32
|
-
|
|
36
|
+
# Development (with hot reload)
|
|
37
|
+
npm run dev -- --name <name> --url <hub-url> [options]
|
|
38
|
+
|
|
39
|
+
# Production (after building)
|
|
40
|
+
npm run build
|
|
41
|
+
npm start -- --name <name> --url <hub-url> [options]
|
|
33
42
|
```
|
|
34
43
|
|
|
35
|
-
|
|
44
|
+
Or install globally to use the `flowy-runner` command directly:
|
|
36
45
|
|
|
37
46
|
```bash
|
|
38
|
-
|
|
47
|
+
npm install -g @frankleeeee/flowy-runner
|
|
48
|
+
flowy-runner --name <name> --url <hub-url> [options]
|
|
39
49
|
```
|
|
40
50
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
### Required
|
|
44
|
-
|
|
45
|
-
| Flag | Description |
|
|
46
|
-
|------|-------------|
|
|
47
|
-
| `--name <name>` | Unique runner name |
|
|
48
|
-
| `--url <url>` | Flowy backend URL |
|
|
49
|
-
|
|
50
|
-
### Optional
|
|
51
|
+
### Required flags
|
|
51
52
|
|
|
52
53
|
| Flag | Description |
|
|
53
54
|
|------|-------------|
|
|
54
|
-
| `--
|
|
55
|
-
| `--
|
|
56
|
-
| `--secret <secret>` | Registration secret if the server requires one |
|
|
57
|
-
| `--device <info>` | Device info string. Default: hostname |
|
|
55
|
+
| `--name <name>` | Unique name for this runner (e.g. `macbook-pro`) |
|
|
56
|
+
| `--url <url>` | URL of the Flowy hub backend (e.g. `http://localhost:3001`) |
|
|
58
57
|
|
|
59
|
-
|
|
58
|
+
### Optional flags
|
|
60
59
|
|
|
61
|
-
|
|
60
|
+
| Flag | Default | Description |
|
|
61
|
+
|------|---------|-------------|
|
|
62
|
+
| `--poll-interval <ms>` | `5000` | How often to poll for new tasks (in milliseconds) |
|
|
63
|
+
| `--token <token>` | — | Reuse an existing runner token instead of registering |
|
|
64
|
+
| `--secret <secret>` | — | Registration secret (if the hub requires one) |
|
|
65
|
+
| `--device <info>` | auto-detected | Device info string sent during registration |
|
|
62
66
|
|
|
63
|
-
|
|
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
|
|
67
|
+
### Examples
|
|
69
68
|
|
|
70
|
-
|
|
69
|
+
**Development** (with hot reload):
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
71
|
+
```bash
|
|
72
|
+
cd runner
|
|
73
|
+
npm run dev -- --name my-laptop --url http://localhost:3001
|
|
90
74
|
```
|
|
91
75
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
## Development
|
|
95
|
-
|
|
96
|
-
From the `runner/` directory:
|
|
76
|
+
**Production** (compiled):
|
|
97
77
|
|
|
98
78
|
```bash
|
|
99
|
-
|
|
100
|
-
npm run
|
|
79
|
+
cd runner
|
|
80
|
+
npm run build
|
|
81
|
+
npm start -- --name my-laptop --url http://localhost:3001
|
|
101
82
|
```
|
|
102
83
|
|
|
103
|
-
|
|
84
|
+
**Global install** (use from anywhere):
|
|
104
85
|
|
|
105
86
|
```bash
|
|
106
|
-
|
|
87
|
+
cd runner
|
|
88
|
+
npm run build
|
|
89
|
+
npm link
|
|
90
|
+
flowy-runner --name office-server --url https://hub.example.com --secret my-secret
|
|
107
91
|
```
|
|
108
92
|
|
|
109
|
-
##
|
|
93
|
+
## How it works
|
|
110
94
|
|
|
111
|
-
|
|
95
|
+
1. **Register** — On first launch, the runner registers with the hub and receives an authentication token. The token is saved to `~/.config/my-hub/runner-<name>.json` for future sessions.
|
|
112
96
|
|
|
113
|
-
|
|
114
|
-
npm run build
|
|
115
|
-
```
|
|
97
|
+
2. **Heartbeat** — Every 30 seconds, the runner sends a heartbeat to the hub so it appears as "online" in the dashboard.
|
|
116
98
|
|
|
117
|
-
|
|
99
|
+
3. **Poll** — Every 5 seconds (configurable), the runner polls the hub for tasks assigned to it with status `todo`.
|
|
118
100
|
|
|
119
|
-
|
|
120
|
-
npm run build --workspace=runner
|
|
121
|
-
```
|
|
101
|
+
4. **Detect CLIs** — On startup, the runner checks the local machine for supported commands (`claude`, `codex`, `agent`) and registers only the providers it finds.
|
|
122
102
|
|
|
123
|
-
|
|
103
|
+
5. **Execute** — When a task is picked up, the runner spawns the appropriate AI CLI tool as a child process:
|
|
104
|
+
| Provider | Command |
|
|
105
|
+
|----------|---------|
|
|
106
|
+
| `claude-code` | `claude -p "<task description>"` |
|
|
107
|
+
| `codex` | `codex exec "<task description>"` |
|
|
108
|
+
| `cursor-agent` | `agent --print --force "<task description>"` |
|
|
109
|
+
|
|
110
|
+
6. **Stream output** — Output is buffered and sent back to the hub every 2 seconds so you can monitor progress in real time from the web UI.
|
|
111
|
+
|
|
112
|
+
7. **Complete** — Once the process exits, the runner reports success or failure to the hub.
|
|
113
|
+
|
|
114
|
+
## Token persistence
|
|
124
115
|
|
|
125
|
-
|
|
116
|
+
Runner tokens are saved to `~/.config/my-hub/runner-<name>.json`. On subsequent launches with the same `--name`, the saved token is reused automatically. You can also pass `--token <token>` to use a specific token.
|
|
126
117
|
|
|
127
|
-
|
|
118
|
+
## Graceful shutdown
|
|
128
119
|
|
|
129
|
-
|
|
130
|
-
- run the `Publish Flowy Runner` workflow manually with a version
|
|
120
|
+
The runner handles `SIGINT` and `SIGTERM` signals for clean shutdown. Press `Ctrl+C` to stop the runner gracefully.
|
package/dist/cli.js
CHANGED
|
File without changes
|
package/dist/config.js
CHANGED
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.childProcess = void 0;
|
|
6
7
|
exports.parseArgs = parseArgs;
|
|
7
8
|
exports.detectAvailableProviders = detectAvailableProviders;
|
|
8
9
|
exports.saveToken = saveToken;
|
|
@@ -11,10 +12,12 @@ const os_1 = __importDefault(require("os"));
|
|
|
11
12
|
const fs_1 = __importDefault(require("fs"));
|
|
12
13
|
const path_1 = __importDefault(require("path"));
|
|
13
14
|
const child_process_1 = require("child_process");
|
|
14
|
-
|
|
15
|
+
exports.childProcess = { spawnSync: child_process_1.spawnSync };
|
|
16
|
+
const CONFIG_DIR = path_1.default.join(os_1.default.homedir(), '.config', 'my-hub');
|
|
15
17
|
const SUPPORTED_PROVIDERS = [
|
|
16
18
|
{ id: 'claude-code', command: 'claude' },
|
|
17
19
|
{ id: 'codex', command: 'codex' },
|
|
20
|
+
{ id: 'cursor-agent', command: 'agent' },
|
|
18
21
|
];
|
|
19
22
|
function parseArgs(argv) {
|
|
20
23
|
const args = argv.slice(2);
|
|
@@ -47,11 +50,11 @@ function parseArgs(argv) {
|
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
if (!name || !url) {
|
|
50
|
-
console.error('Usage: flowy-runner --name <name> --url <
|
|
53
|
+
console.error('Usage: flowy-runner --name <name> --url <hub-url> [options]');
|
|
51
54
|
console.error('');
|
|
52
55
|
console.error('Required:');
|
|
53
56
|
console.error(' --name Runner name');
|
|
54
|
-
console.error(' --url
|
|
57
|
+
console.error(' --url Hub URL (e.g. http://localhost:3001)');
|
|
55
58
|
console.error('');
|
|
56
59
|
console.error('Options:');
|
|
57
60
|
console.error(' --poll-interval Poll interval in seconds (default: 5)');
|
|
@@ -67,7 +70,7 @@ function parseArgs(argv) {
|
|
|
67
70
|
const providers = detectAvailableProviders();
|
|
68
71
|
if (providers.length === 0) {
|
|
69
72
|
console.error('No supported AI CLIs were detected on this machine.');
|
|
70
|
-
console.error('Install one of: claude, codex');
|
|
73
|
+
console.error('Install one of: claude, codex, agent');
|
|
71
74
|
process.exit(1);
|
|
72
75
|
}
|
|
73
76
|
return {
|
|
@@ -88,7 +91,7 @@ function detectAvailableProviders() {
|
|
|
88
91
|
}
|
|
89
92
|
function isCommandAvailable(command) {
|
|
90
93
|
const checker = process.platform === 'win32' ? 'where' : 'which';
|
|
91
|
-
const result =
|
|
94
|
+
const result = exports.childProcess.spawnSync(checker, [command], { stdio: 'ignore' });
|
|
92
95
|
return result.status === 0;
|
|
93
96
|
}
|
|
94
97
|
function loadToken(name) {
|
package/dist/daemon.js
CHANGED
|
@@ -19,7 +19,7 @@ async function startDaemon(config) {
|
|
|
19
19
|
api.setToken(token);
|
|
20
20
|
(0, config_1.saveToken)(config.name, id, token);
|
|
21
21
|
console.log(`Registered! Runner ID: ${id}`);
|
|
22
|
-
console.log(`Token saved to ~/.config/
|
|
22
|
+
console.log(`Token saved to ~/.config/my-hub/runner-${config.name}.json`);
|
|
23
23
|
}
|
|
24
24
|
let executing = false;
|
|
25
25
|
let killCurrent = null;
|
package/dist/executor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildCommand = buildCommand;
|
|
3
4
|
exports.executeTask = executeTask;
|
|
4
5
|
const child_process_1 = require("child_process");
|
|
5
6
|
/** Build the CLI command + args for a given AI provider. */
|
|
@@ -13,6 +14,8 @@ function buildCommand(aiProvider, prompt) {
|
|
|
13
14
|
args: ['exec', prompt, '--sandbox', 'workspace-write', '--color', 'never'],
|
|
14
15
|
streamOutput: true,
|
|
15
16
|
};
|
|
17
|
+
case 'cursor-agent':
|
|
18
|
+
return { cmd: 'agent', args: ['--print', '--force', prompt], streamOutput: true };
|
|
16
19
|
default:
|
|
17
20
|
throw new Error(`Unknown AI provider: ${aiProvider}`);
|
|
18
21
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frankleeeee/flowy-runner",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "CLI daemon that connects to a Flowy hub and executes AI tasks locally",
|
|
5
5
|
"bin": {
|
|
6
6
|
"flowy-runner": "./dist/cli.js"
|
|
7
7
|
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist/"
|
|
10
|
+
],
|
|
8
11
|
"scripts": {
|
|
9
12
|
"dev": "ts-node-dev --respawn --transpile-only src/cli.ts --",
|
|
10
13
|
"build": "tsc",
|
|
@@ -13,15 +16,17 @@
|
|
|
13
16
|
"dependencies": {
|
|
14
17
|
"axios": "^1.6.8"
|
|
15
18
|
},
|
|
16
|
-
"publishConfig": {
|
|
17
|
-
"access": "public"
|
|
18
|
-
},
|
|
19
|
-
"engines": {
|
|
20
|
-
"node": ">=23"
|
|
21
|
-
},
|
|
22
19
|
"devDependencies": {
|
|
23
20
|
"@types/node": "^20.11.30",
|
|
24
21
|
"ts-node-dev": "^2.0.0",
|
|
25
22
|
"typescript": "^5.4.3"
|
|
26
|
-
}
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/FrankLeeeee/Flowy"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT"
|
|
27
32
|
}
|
package/src/api.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
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
|
-
}
|
package/src/executor.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
}
|