@amenophis1er/foreman 0.1.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/DESIGN.md +408 -0
- package/LICENSE +15 -0
- package/README.md +133 -0
- package/bin/foreman.mjs +58 -0
- package/package.json +68 -0
- package/scripts/prepare.mjs +48 -0
- package/skills/director/SKILL.md +65 -0
- package/src/anthropic-models.ts +54 -0
- package/src/ask.test.ts +88 -0
- package/src/ask.ts +95 -0
- package/src/attachments.test.ts +33 -0
- package/src/attachments.ts +60 -0
- package/src/cli.test.ts +27 -0
- package/src/cli.ts +297 -0
- package/src/codex.test.ts +328 -0
- package/src/codex.ts +196 -0
- package/src/cost-basis.test.ts +76 -0
- package/src/deck.test.ts +402 -0
- package/src/deck.ts +892 -0
- package/src/fork.test.ts +31 -0
- package/src/gateway/ledger.cjs +326 -0
- package/src/gateway/ledger.test.ts +255 -0
- package/src/gateway/llm-gateway.cjs +1411 -0
- package/src/gateway/llm-gateway.test.ts +478 -0
- package/src/gateway.test.ts +226 -0
- package/src/gateway.ts +309 -0
- package/src/instance.ts +124 -0
- package/src/models.test.ts +147 -0
- package/src/models.ts +158 -0
- package/src/notify/commands.test.ts +28 -0
- package/src/notify/commands.ts +73 -0
- package/src/notify/telegram.ts +259 -0
- package/src/notify.test.ts +343 -0
- package/src/notify.ts +495 -0
- package/src/ollama.test.ts +49 -0
- package/src/ollama.ts +49 -0
- package/src/openai-prices.test.ts +58 -0
- package/src/openai-prices.ts +106 -0
- package/src/orchestrator.test.ts +1147 -0
- package/src/orchestrator.ts +2325 -0
- package/src/planner.test.ts +60 -0
- package/src/planner.ts +505 -0
- package/src/policy.test.ts +411 -0
- package/src/policy.ts +599 -0
- package/src/preflight.ts +348 -0
- package/src/prices.test.ts +69 -0
- package/src/prices.ts +90 -0
- package/src/provider.test.ts +366 -0
- package/src/provider.ts +502 -0
- package/src/secrets.test.ts +143 -0
- package/src/secrets.ts +66 -0
- package/src/server.ts +1992 -0
- package/src/services.test.ts +53 -0
- package/src/services.ts +102 -0
- package/src/sse-events.test.ts +83 -0
- package/src/store.test.ts +119 -0
- package/src/store.ts +346 -0
- package/src/tailscale.test.ts +32 -0
- package/src/tailscale.ts +79 -0
- package/src/title.ts +138 -0
- package/src/types.ts +442 -0
- package/ui/dist/assets/index-LAj0Dy9p.css +1 -0
- package/ui/dist/assets/index-lcBy-uRZ.js +65 -0
- package/ui/dist/favicon.svg +8 -0
- package/ui/dist/index.html +14 -0
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@amenophis1er/foreman",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude",
|
|
7
|
+
"agent",
|
|
8
|
+
"agents",
|
|
9
|
+
"orchestrator",
|
|
10
|
+
"autonomous",
|
|
11
|
+
"mission",
|
|
12
|
+
"claude-code",
|
|
13
|
+
"dashboard"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/amenophis1er/foreman#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/amenophis1er/foreman/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/amenophis1er/foreman.git"
|
|
22
|
+
},
|
|
23
|
+
"license": "ISC",
|
|
24
|
+
"author": "Amen AMOUZOU",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"foreman": "./bin/foreman.mjs"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin",
|
|
31
|
+
"src",
|
|
32
|
+
"scripts/prepare.mjs",
|
|
33
|
+
"ui/dist",
|
|
34
|
+
"skills",
|
|
35
|
+
"README.md",
|
|
36
|
+
"DESIGN.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "node --import tsx --test 'src/**/*.test.ts'",
|
|
44
|
+
"start": "tsx src/server.ts",
|
|
45
|
+
"dev": "node scripts/dev.mjs",
|
|
46
|
+
"ui:dev": "npm --prefix ui run dev",
|
|
47
|
+
"ui:build": "npm --prefix ui run build",
|
|
48
|
+
"prepack": "node scripts/prepare.mjs --force",
|
|
49
|
+
"typecheck": "tsc --noEmit -p . && npm --prefix ui run typecheck",
|
|
50
|
+
"doctor": "node bin/foreman.mjs doctor",
|
|
51
|
+
"setup": "node scripts/prepare.mjs --force"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.259",
|
|
55
|
+
"@playwright/mcp": "^0.0.80",
|
|
56
|
+
"qrcode": "^1.5.4",
|
|
57
|
+
"tsx": "^4.23.13",
|
|
58
|
+
"zod": "^4.5.4"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@types/node": "^26.4.1",
|
|
62
|
+
"@types/qrcode": "^1.5.6",
|
|
63
|
+
"typescript": "^7.0.2"
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Builds the dashboard so the package ships with one:
|
|
3
|
+
// npm publish runs it via prepack (--force) — every tarball has ui/dist
|
|
4
|
+
// npm run setup in a checkout, after npm ci
|
|
5
|
+
// Not a `prepare`/`postinstall` hook on purpose: npm 11 warns about (and
|
|
6
|
+
// blocks) lifecycle scripts on global installs, and the tarball already has
|
|
7
|
+
// the build. Skips when nothing changed; `--force` always builds.
|
|
8
|
+
// Never fails an install that cannot build: the API still works without the
|
|
9
|
+
// dashboard, and the preflight says so on start.
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
11
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
const ui = path.join(root, 'ui');
|
|
17
|
+
const dist = path.join(ui, 'dist', 'index.html');
|
|
18
|
+
const force = process.argv.includes('--force');
|
|
19
|
+
const quiet = process.env.FOREMAN_SKIP_UI_BUILD === '1';
|
|
20
|
+
|
|
21
|
+
if (quiet) process.exit(0);
|
|
22
|
+
if (!existsSync(path.join(ui, 'package.json'))) process.exit(0); // not shipped with sources — nothing to build
|
|
23
|
+
|
|
24
|
+
function newest(dir) {
|
|
25
|
+
let t = 0;
|
|
26
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
27
|
+
if (e.name === 'node_modules' || e.name === 'dist') continue;
|
|
28
|
+
const p = path.join(dir, e.name);
|
|
29
|
+
t = Math.max(t, e.isDirectory() ? newest(p) : statSync(p).mtimeMs);
|
|
30
|
+
}
|
|
31
|
+
return t;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const stale = force || !existsSync(dist) || newest(path.join(ui, 'src')) > statSync(dist).mtimeMs
|
|
35
|
+
|| statSync(path.join(ui, 'package.json')).mtimeMs > statSync(dist).mtimeMs;
|
|
36
|
+
if (!stale) process.exit(0);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
if (!existsSync(path.join(ui, 'node_modules'))) {
|
|
40
|
+
console.log('foreman: installing dashboard dependencies…');
|
|
41
|
+
execSync('npm ci --no-audit --no-fund', { cwd: ui, stdio: 'inherit' });
|
|
42
|
+
}
|
|
43
|
+
console.log('foreman: building the dashboard…');
|
|
44
|
+
execSync('npm run build', { cwd: ui, stdio: 'inherit' });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (force) { console.error('foreman: dashboard build failed — refusing to pack without it'); process.exit(1); }
|
|
47
|
+
console.warn(`foreman: dashboard build skipped (${err.message.split('\n')[0]}). The API works; the dashboard will not until \`npm run ui:build\`.`);
|
|
48
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: director
|
|
3
|
+
description: Kick off a Foreman mission for the current project — ensures the Foreman server is running, links the session's cwd as a project, starts a director-led mission with the given brief, and opens the dashboard. Usage - /director <mission brief> [--budget N] [--worker sonnet|haiku|opus] [--director opus|sonnet|haiku]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /director — launch a Foreman mission from this session
|
|
7
|
+
|
|
8
|
+
You are the launcher only. The mission itself runs in Foreman's own director
|
|
9
|
+
and worker sessions with budgets, approval cards, and MISSION.md governance;
|
|
10
|
+
the human supervises from the dashboard, not from this session. Do NOT do the
|
|
11
|
+
mission's work yourself here.
|
|
12
|
+
|
|
13
|
+
Foreman serves `http://localhost:4177` and is started with the `foreman`
|
|
14
|
+
command. Set `FOREMAN_URL` to point at a different host or port.
|
|
15
|
+
|
|
16
|
+
## Steps
|
|
17
|
+
|
|
18
|
+
1. **Safety guard.** If the current working directory is inside a Foreman
|
|
19
|
+
checkout, STOP and tell the user: Foreman must never run missions on itself
|
|
20
|
+
(oversight-infrastructure rule). A directory whose `package.json` has a
|
|
21
|
+
`foreman` bin, or which contains `src/orchestrator.ts` alongside
|
|
22
|
+
`src/policy.ts`, is a Foreman checkout.
|
|
23
|
+
|
|
24
|
+
2. **Parse the arguments.** Everything except the flags is the mission brief.
|
|
25
|
+
Flags: `--budget N` (default 5), `--worker <model>`, `--director <model>`
|
|
26
|
+
(models: opus | sonnet | haiku; omit for default). If there is no brief at
|
|
27
|
+
all, skip steps 4–5: just ensure the server (step 3), link the project
|
|
28
|
+
(step 4's curl), and open the dashboard.
|
|
29
|
+
|
|
30
|
+
3. **Ensure the server is up** (start detached if not):
|
|
31
|
+
```bash
|
|
32
|
+
curl -sf -m 2 "${FOREMAN_URL:-http://localhost:4177}/projects" >/dev/null || \
|
|
33
|
+
(nohup foreman start > /tmp/foreman-server.log 2>&1 & disown; sleep 3)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
4. **Link the cwd as a project** (idempotent — relinking returns the existing
|
|
37
|
+
project). Capture the project id from the JSON response:
|
|
38
|
+
```bash
|
|
39
|
+
curl -s -X POST "${FOREMAN_URL:-http://localhost:4177}/projects" \
|
|
40
|
+
-H 'content-type: application/json' \
|
|
41
|
+
-d "{\"folder\": \"$PWD\"}"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
5. **Start the mission** (include directorModel/workerModel keys only when
|
|
45
|
+
the flags were given):
|
|
46
|
+
```bash
|
|
47
|
+
curl -s -X POST "${FOREMAN_URL:-http://localhost:4177}/run" \
|
|
48
|
+
-H 'content-type: application/json' \
|
|
49
|
+
-d '{"projectId": "<id>", "mission": "<brief>", "budgetUsd": <budget>}'
|
|
50
|
+
```
|
|
51
|
+
A 409 means this project already has an active mission — report that and
|
|
52
|
+
still open the dashboard.
|
|
53
|
+
|
|
54
|
+
6. **Open the dashboard and hand off.** Auto-open the project view
|
|
55
|
+
(ignore failure — e.g. over SSH):
|
|
56
|
+
```bash
|
|
57
|
+
open "${FOREMAN_URL:-http://localhost:4177}/#/p/<projectId>" 2>/dev/null || true
|
|
58
|
+
```
|
|
59
|
+
Then ALWAYS print the control URL verbatim in your final message — it is
|
|
60
|
+
the single place to approve tools and answer the director's questions:
|
|
61
|
+
|
|
62
|
+
> Mission running under a $N budget.
|
|
63
|
+
> **Control it here: http://localhost:4177/#/p/<projectId>**
|
|
64
|
+
> Approvals and questions wait there indefinitely (nothing expires);
|
|
65
|
+
> this terminal session is free for other work.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Anthropic model list — the one catalogue Foreman cannot ask for.
|
|
3
|
+
*
|
|
4
|
+
* **Edit this file when Anthropic ships or retires a model.** It is the only
|
|
5
|
+
* hardcoded model list in the product, and it is deliberately in a file of its
|
|
6
|
+
* own so it is obvious where to look.
|
|
7
|
+
*
|
|
8
|
+
* Every other provider is queried live: Ollama answers `/api/tags`, an
|
|
9
|
+
* OpenAI-compatible endpoint answers `/v1/models`, and Codex keeps its own
|
|
10
|
+
* `models_cache.json` that the CLI maintains. Anthropic has an API for this
|
|
11
|
+
* too — `GET /v1/models` — but it needs an API key, and Foreman's common case
|
|
12
|
+
* is a Claude Code *subscription*, which has no key to send. So this list is
|
|
13
|
+
* the fallback for the case nothing can be queried.
|
|
14
|
+
*
|
|
15
|
+
* What actually reaches the SDK is the `id` — the alias. Aliases keep
|
|
16
|
+
* resolving as Anthropic updates the models behind them, so a stale `model`
|
|
17
|
+
* string here misleads the eye without breaking a run. That is the reason this
|
|
18
|
+
* list drifting is survivable, not a reason to leave it wrong.
|
|
19
|
+
*
|
|
20
|
+
* `cost` is a 1–4 relative rank for the picker's bars, not a price.
|
|
21
|
+
*/
|
|
22
|
+
export interface CatalogModel {
|
|
23
|
+
/** Sent to the SDK as `options.model`. An alias, so it survives model updates. */
|
|
24
|
+
id: string;
|
|
25
|
+
label: string;
|
|
26
|
+
/** The concrete model behind the alias, shown in mono. Display only. */
|
|
27
|
+
model: string;
|
|
28
|
+
cost: 1 | 2 | 3 | 4;
|
|
29
|
+
note: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const ANTHROPIC_MODELS: CatalogModel[] = [
|
|
33
|
+
{
|
|
34
|
+
id: 'fable', label: 'Fable', model: 'claude-fable-5-1', cost: 4,
|
|
35
|
+
note: 'Frontier. Long-horizon planning and verification.',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'opus', label: 'Opus', model: 'claude-opus-5', cost: 3,
|
|
39
|
+
note: 'Deep reasoning for hard refactors.',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'sonnet', label: 'Sonnet', model: 'claude-sonnet-5', cost: 2,
|
|
43
|
+
note: 'Balanced. The usual worker.',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'haiku', label: 'Haiku', model: 'claude-haiku-4-5', cost: 1,
|
|
47
|
+
note: 'Fast and cheap for reads and mechanical edits.',
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Aliases, for the places that need to know a choice is an Anthropic one. */
|
|
52
|
+
export const ANTHROPIC_ALIASES: ReadonlySet<string> = new Set(
|
|
53
|
+
ANTHROPIC_MODELS.map((m) => m.id),
|
|
54
|
+
);
|
package/src/ask.test.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking a human — the mechanics every ask surface shares.
|
|
3
|
+
*
|
|
4
|
+
* What is pinned here is not the picker; it is the contract between a model
|
|
5
|
+
* that asked in structured form and the prose it gets back, plus the one rule
|
|
6
|
+
* with no exceptions: every ask carries an unattended default, and `0` means
|
|
7
|
+
* "never", not "immediately".
|
|
8
|
+
*/
|
|
9
|
+
import test from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { armAskTimeout, formatAnswers, normaliseQuestions } from './ask.js';
|
|
12
|
+
|
|
13
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
14
|
+
|
|
15
|
+
test('answers are echoed question by question, in the order asked', () => {
|
|
16
|
+
const qs = normaliseQuestions([
|
|
17
|
+
{ question: 'Stack?', options: ['plain HTML', 'React'] },
|
|
18
|
+
{ question: 'Imagery?', options: ['stock photos', 'you provide assets'] },
|
|
19
|
+
]);
|
|
20
|
+
const out = formatAnswers(qs, { 'Stack?': 'plain HTML', 'Imagery?': 'stock photos' });
|
|
21
|
+
assert.equal(out, '• Stack?\n → plain HTML\n• Imagery?\n → stock photos');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('an unanswered question is said to be unanswered, never silently dropped', () => {
|
|
25
|
+
// Silence must not read as agreement with the recommended option; the
|
|
26
|
+
// model is told to decide, which is the same instruction the timeout gives.
|
|
27
|
+
const qs = normaliseQuestions([{ question: 'Scope?', options: ['one page', 'multi-page'] }]);
|
|
28
|
+
assert.match(formatAnswers(qs, {}), /no answer — decide yourself/);
|
|
29
|
+
assert.match(formatAnswers(qs, { 'Scope?': ' ' }), /no answer — decide yourself/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('options may be strings or {label, hint}, and are trimmed', () => {
|
|
33
|
+
const [q] = normaliseQuestions([{
|
|
34
|
+
question: ' Stack? ',
|
|
35
|
+
options: [' plain HTML ', { label: 'React', hint: 'needs a build step' }, { label: ' ' }, 42],
|
|
36
|
+
multi: true,
|
|
37
|
+
}]);
|
|
38
|
+
assert.equal(q.question, 'Stack?');
|
|
39
|
+
// A string option carries no hint key at all — not `hint: undefined` —
|
|
40
|
+
// so the JSON the picker receives is exactly as small as what was sent.
|
|
41
|
+
assert.deepEqual(q.options, [
|
|
42
|
+
{ label: 'plain HTML' },
|
|
43
|
+
{ label: 'React', hint: 'needs a build step' },
|
|
44
|
+
]);
|
|
45
|
+
assert.equal(q.multi, true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('at most three questions and six options each, and a blank question is dropped', () => {
|
|
49
|
+
const qs = normaliseQuestions([
|
|
50
|
+
{ question: 'a', options: ['1', '2', '3', '4', '5', '6', '7', '8'] },
|
|
51
|
+
{ question: '', options: ['x'] },
|
|
52
|
+
{ question: 'b', options: ['1'] },
|
|
53
|
+
{ question: 'c', options: ['1'] },
|
|
54
|
+
{ question: 'd', options: ['1'] },
|
|
55
|
+
]);
|
|
56
|
+
// 'a', (blank dropped), 'b', 'c' — the fourth real question is past the cap
|
|
57
|
+
// because the cap applies to what the model sent, not to what survived.
|
|
58
|
+
assert.deepEqual(qs.map((q) => q.question), ['a', 'b']);
|
|
59
|
+
assert.equal(qs[0].options.length, 6);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('non-array input yields no questions rather than throwing', () => {
|
|
63
|
+
assert.deepEqual(normaliseQuestions(undefined), []);
|
|
64
|
+
assert.deepEqual(normaliseQuestions('Stack?'), []);
|
|
65
|
+
assert.deepEqual(normaliseQuestions({ question: 'Stack?' }), []);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('the ask timer fires once, cancel prevents it, and zero never arms', async () => {
|
|
69
|
+
let fired = 0;
|
|
70
|
+
armAskTimeout(15, () => fired++);
|
|
71
|
+
await sleep(60);
|
|
72
|
+
assert.equal(fired, 1);
|
|
73
|
+
|
|
74
|
+
const b = armAskTimeout(15, () => fired++);
|
|
75
|
+
b.cancel();
|
|
76
|
+
await sleep(40);
|
|
77
|
+
assert.equal(fired, 1, 'a cancelled ask must not fire');
|
|
78
|
+
|
|
79
|
+
const c = armAskTimeout(0, () => fired++);
|
|
80
|
+
await sleep(30);
|
|
81
|
+
assert.equal(fired, 1, '0 means never, not immediately');
|
|
82
|
+
c.cancel(); // must be safe on a timer that was never armed
|
|
83
|
+
|
|
84
|
+
armAskTimeout(NaN, () => fired++);
|
|
85
|
+
armAskTimeout(-5, () => fired++);
|
|
86
|
+
await sleep(30);
|
|
87
|
+
assert.equal(fired, 1, 'nonsense durations never arm either');
|
|
88
|
+
});
|
package/src/ask.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking a human — the small mechanics shared by every place Foreman does it.
|
|
3
|
+
*
|
|
4
|
+
* Three surfaces ask: the director's `ask_human`, a permission prompt, and
|
|
5
|
+
* the planner's `ask_user`. They differ in what they ask and what a good
|
|
6
|
+
* unattended default is; they must not differ in the mechanics, because the
|
|
7
|
+
* mechanics are where the rule lives: **every ask carries an unattended
|
|
8
|
+
* default.** Foreman is an autonomous orchestrator, and a question that
|
|
9
|
+
* blocks indefinitely turns a governance feature into an outage.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One structured question, the shape a picker renders.
|
|
14
|
+
*
|
|
15
|
+
* Options are what make a question fast to answer — a click instead of a
|
|
16
|
+
* sentence — and putting the recommended one first is what makes the common
|
|
17
|
+
* case one click. `multi` is for "which of these" questions; the default is a
|
|
18
|
+
* single choice because most genuinely blocking questions are forks.
|
|
19
|
+
*/
|
|
20
|
+
export interface AskOption {
|
|
21
|
+
label: string;
|
|
22
|
+
/** One line under the label: what choosing this implies. */
|
|
23
|
+
hint?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AskQuestion {
|
|
27
|
+
question: string;
|
|
28
|
+
options: AskOption[];
|
|
29
|
+
multi?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A batch of questions with one id, answered together. */
|
|
33
|
+
export interface PendingAsk {
|
|
34
|
+
id: string;
|
|
35
|
+
questions: AskQuestion[];
|
|
36
|
+
askedAt: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Answers keyed by question text; a multi answer joins its labels with ", ". */
|
|
40
|
+
export type AskAnswers = Record<string, string>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Arms a timer that fires `onTimeout` once after `ms`, or never when `ms` is
|
|
44
|
+
* zero. Trivial on purpose: the rule "0 disables, otherwise it fires" is worth
|
|
45
|
+
* being explicit and testable, since forgetting the zero case would make a
|
|
46
|
+
* per-run override of "never time out" silently time out anyway.
|
|
47
|
+
*/
|
|
48
|
+
export function armAskTimeout(ms: number, onTimeout: () => void): { cancel(): void } {
|
|
49
|
+
if (!Number.isFinite(ms) || ms <= 0) return { cancel() { /* never armed */ } };
|
|
50
|
+
let fired = false;
|
|
51
|
+
const t = setTimeout(() => { fired = true; onTimeout(); }, ms);
|
|
52
|
+
t.unref?.();
|
|
53
|
+
return { cancel() { if (!fired) clearTimeout(t); } };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Renders answers back to the agent as prose it cannot misread.
|
|
58
|
+
*
|
|
59
|
+
* The agent asked in structured form and gets a structured echo: question,
|
|
60
|
+
* arrow, answer. An unanswered question is said to be unanswered rather than
|
|
61
|
+
* omitted, so the agent does not assume silence meant agreement with its
|
|
62
|
+
* recommended option.
|
|
63
|
+
*/
|
|
64
|
+
export function formatAnswers(questions: AskQuestion[], answers: AskAnswers): string {
|
|
65
|
+
return questions
|
|
66
|
+
.map((q) => {
|
|
67
|
+
const a = answers[q.question];
|
|
68
|
+
return `• ${q.question}\n → ${a && a.trim() ? a.trim() : '(no answer — decide yourself)'}`;
|
|
69
|
+
})
|
|
70
|
+
.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Validates and normalises what a model sent to an ask tool. */
|
|
74
|
+
export function normaliseQuestions(raw: unknown): AskQuestion[] {
|
|
75
|
+
const arr = Array.isArray(raw) ? raw : [];
|
|
76
|
+
const out: AskQuestion[] = [];
|
|
77
|
+
for (const q of arr.slice(0, 3) as Array<Record<string, unknown>>) {
|
|
78
|
+
const question = typeof q?.question === 'string' ? q.question.trim() : '';
|
|
79
|
+
if (!question) continue;
|
|
80
|
+
const options: AskOption[] = [];
|
|
81
|
+
for (const o of (Array.isArray(q.options) ? q.options : []).slice(0, 6)) {
|
|
82
|
+
if (typeof o === 'string' && o.trim()) options.push({ label: o.trim() });
|
|
83
|
+
else if (o && typeof o === 'object' && typeof (o as AskOption).label === 'string') {
|
|
84
|
+
const { label, hint } = o as AskOption;
|
|
85
|
+
if (label.trim()) options.push({ label: label.trim(), hint: typeof hint === 'string' ? hint : undefined });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// A question with fewer than two options is not a choice; it is a prompt
|
|
89
|
+
// for free text, and the picker still offers "something else", so it is
|
|
90
|
+
// kept rather than rejected — but a model that sends none is told so by
|
|
91
|
+
// the tool's own validation upstream.
|
|
92
|
+
out.push({ question, options, multi: q.multi === true });
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { mkdtemp, readFile } from 'node:fs/promises';
|
|
6
|
+
import { attachmentLines, safeName, saveAttachments } from './attachments.js';
|
|
7
|
+
|
|
8
|
+
test('safeName keeps the extension and drops what a shell or a path would choke on', () => {
|
|
9
|
+
assert.equal(safeName('my screenshot (1).png'), 'my-screenshot-1-.png'.replace('-1-.png', '-1-.png'));
|
|
10
|
+
assert.equal(safeName('../../etc/passwd'), 'passwd');
|
|
11
|
+
assert.equal(safeName(''), 'file');
|
|
12
|
+
assert.equal(safeName('..'), 'file');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('saveAttachments writes under .foreman/attachments and reports project-relative paths', async () => {
|
|
16
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'attach-'));
|
|
17
|
+
const saved = await saveAttachments(dir, [
|
|
18
|
+
{ name: 'spec.md', data: Buffer.from('# spec').toString('base64') },
|
|
19
|
+
{ name: 'shot.png', data: Buffer.from([0x89, 0x50]).toString('base64') },
|
|
20
|
+
], new Date(2026, 8, 5, 19, 44, 12));
|
|
21
|
+
assert.equal(saved.length, 2);
|
|
22
|
+
assert.equal(saved[0].path, '.foreman/attachments/20260905-194412-1-spec.md');
|
|
23
|
+
assert.equal(saved[1].path, '.foreman/attachments/20260905-194412-2-shot.png');
|
|
24
|
+
assert.equal(await readFile(path.join(dir, saved[0].path), 'utf8'), '# spec');
|
|
25
|
+
assert.match(attachmentLines(saved), /Attached files.*\n- \.foreman\/attachments\/20260905-194412-1-spec\.md\n- /s);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('saveAttachments refuses empty, oversized and too many files with a plain message', async () => {
|
|
29
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'attach-'));
|
|
30
|
+
await assert.rejects(saveAttachments(dir, [{ name: 'x', data: '' }]), /is empty/);
|
|
31
|
+
await assert.rejects(saveAttachments(dir, Array.from({ length: 11 }, () => ({ name: 'x', data: 'AA==' }))), /at most 10/);
|
|
32
|
+
assert.deepEqual(await saveAttachments(dir, []), []);
|
|
33
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files a person hands to the planner or the director: a screenshot of the
|
|
3
|
+
* bug, a spec, a CSV. They land in the project folder — `.foreman/attachments/`
|
|
4
|
+
* — because that is the one place both the planner (read-only) and the crew
|
|
5
|
+
* can reach without any new permission, and the message that carries them
|
|
6
|
+
* names the paths. The folder self-ignores in git, so nothing leaks into the
|
|
7
|
+
* user's history. Files, not blobs in a database: the deck lists them as
|
|
8
|
+
* artifacts, and the human can find them with a file manager.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
export const ATTACHMENTS_DIR = path.join('.foreman', 'attachments');
|
|
14
|
+
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
15
|
+
export const MAX_ATTACHMENTS = 10;
|
|
16
|
+
|
|
17
|
+
export interface IncomingAttachment { name: string; data: string /* base64 */; type?: string }
|
|
18
|
+
export interface SavedAttachment { path: string; size: number; name: string }
|
|
19
|
+
|
|
20
|
+
/** A file name the filesystem and a shell both accept, keeping the extension. */
|
|
21
|
+
export function safeName(name: string): string {
|
|
22
|
+
const base = path.basename(String(name || 'file')).replace(/[^\w.\-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
23
|
+
return base && base !== '.' && base !== '..' ? base.slice(0, 120) : 'file';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `20260905-194412-` — sortable, unique enough for a human's pace, readable. */
|
|
27
|
+
function stamp(d = new Date()): string {
|
|
28
|
+
const p = (n: number, l = 2) => String(n).padStart(l, '0');
|
|
29
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Writes the files under the folder and returns their project-relative
|
|
34
|
+
* paths. Throws with a plain message on anything a person can fix (too many,
|
|
35
|
+
* too big, empty), so the route can hand it back as a 400.
|
|
36
|
+
*/
|
|
37
|
+
export async function saveAttachments(folder: string, files: IncomingAttachment[], now = new Date()): Promise<SavedAttachment[]> {
|
|
38
|
+
if (!Array.isArray(files) || files.length === 0) return [];
|
|
39
|
+
if (files.length > MAX_ATTACHMENTS) throw new Error(`at most ${MAX_ATTACHMENTS} files per message`);
|
|
40
|
+
const dir = path.join(folder, ATTACHMENTS_DIR);
|
|
41
|
+
await mkdir(dir, { recursive: true });
|
|
42
|
+
const out: SavedAttachment[] = [];
|
|
43
|
+
const prefix = stamp(now);
|
|
44
|
+
for (const [i, f] of files.entries()) {
|
|
45
|
+
if (!f || typeof f.data !== 'string') throw new Error('each file needs base64 data');
|
|
46
|
+
const buf = Buffer.from(f.data, 'base64');
|
|
47
|
+
if (buf.length === 0) throw new Error(`${f.name || 'a file'} is empty`);
|
|
48
|
+
if (buf.length > MAX_ATTACHMENT_BYTES) throw new Error(`${f.name || 'a file'} is over ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`);
|
|
49
|
+
const name = `${prefix}${files.length > 1 ? `-${i + 1}` : ''}-${safeName(f.name)}`;
|
|
50
|
+
await writeFile(path.join(dir, name), buf, { flag: 'wx' });
|
|
51
|
+
out.push({ path: path.posix.join('.foreman', 'attachments', name), size: buf.length, name: safeName(f.name) });
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The lines appended to a message so an agent knows what came with it. */
|
|
57
|
+
export function attachmentLines(saved: SavedAttachment[]): string {
|
|
58
|
+
if (!saved.length) return '';
|
|
59
|
+
return '\n\nAttached files (in the project folder — read them):\n' + saved.map((s) => `- ${s.path}`).join('\n');
|
|
60
|
+
}
|
package/src/cli.test.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { launchdPlist, servicePath, systemdUnit } from './cli.js';
|
|
4
|
+
|
|
5
|
+
test('launchdPlist: label, node + bin + start, env, keepalive, one log', () => {
|
|
6
|
+
const p = launchdPlist({ label: 'dev.foreman.server', node: '/usr/local/bin/node', bin: '/x/bin/foreman.mjs', home: '/Users/a/.foreman', logDir: '/Users/a/.foreman/logs', env: { PATH: '/a:/b', PORT: '4177', X: 'a<b&c' } });
|
|
7
|
+
assert.match(p, /<key>Label<\/key><string>dev\.foreman\.server<\/string>/);
|
|
8
|
+
assert.match(p, /<string>\/usr\/local\/bin\/node<\/string>\s*<string>\/x\/bin\/foreman\.mjs<\/string>\s*<string>start<\/string>/);
|
|
9
|
+
assert.match(p, /<key>KeepAlive<\/key><true\/>/);
|
|
10
|
+
assert.match(p, /<key>X<\/key><string>a<b&c<\/string>/);
|
|
11
|
+
assert.match(p, /server\.log/);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('systemdUnit: restart always, env lines, default target', () => {
|
|
15
|
+
const u = systemdUnit({ node: '/usr/bin/node', bin: '/x/bin/foreman.mjs', home: '/home/a/.foreman', env: { PORT: '4177' } });
|
|
16
|
+
assert.match(u, /ExecStart=\/usr\/bin\/node \/x\/bin\/foreman\.mjs start/);
|
|
17
|
+
assert.match(u, /Restart=always/);
|
|
18
|
+
assert.match(u, /Environment=PORT=4177/);
|
|
19
|
+
assert.match(u, /WantedBy=default\.target/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('servicePath: node dir first, usual prefixes, no duplicates', () => {
|
|
23
|
+
const p = servicePath('/opt/homebrew/Cellar/node/26/bin/node', '/usr/bin:/bin').split(':');
|
|
24
|
+
assert.equal(p[0], '/opt/homebrew/Cellar/node/26/bin');
|
|
25
|
+
assert.ok(p.includes('/opt/homebrew/bin') && p.includes('/usr/local/bin'));
|
|
26
|
+
assert.equal(new Set(p).size, p.length);
|
|
27
|
+
});
|