@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/src/store.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunStore — durable, append-only persistence for Foreman runs.
|
|
3
|
+
*
|
|
4
|
+
* Layout (default root: ~/.foreman):
|
|
5
|
+
*
|
|
6
|
+
* <root>/runs/<runId>/meta.json — RunMeta, rewritten atomically on change
|
|
7
|
+
* <root>/runs/<runId>/events.jsonl — one ForemanEvent per line, append-only
|
|
8
|
+
* <root>/chats/<projectId>/… — same two files for a project's
|
|
9
|
+
* planning conversation
|
|
10
|
+
*
|
|
11
|
+
* Design notes for reviewers:
|
|
12
|
+
* - The event log is the source of truth for the UI; meta.json is a derived
|
|
13
|
+
* summary kept small so listing runs never reads event logs.
|
|
14
|
+
* - meta.json writes go through tmp-file + rename so a crash mid-write can
|
|
15
|
+
* never corrupt an existing file.
|
|
16
|
+
* - Event appends are serialized per store instance through a promise chain,
|
|
17
|
+
* preserving emit order even when callers don't await.
|
|
18
|
+
* - {@link sweepOrphans} reconciles runs left in status "running" by a
|
|
19
|
+
* previous process: it appends a synthetic `run_finished` event and marks
|
|
20
|
+
* the run interrupted, so replaying a log always terminates cleanly.
|
|
21
|
+
*/
|
|
22
|
+
import { appendFile, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import crypto from 'node:crypto';
|
|
26
|
+
import type {
|
|
27
|
+
ChatMeta, ForemanEvent, Project, ProviderRef, RunMeta, RunSummary, SettingsFile,
|
|
28
|
+
} from './types.js';
|
|
29
|
+
|
|
30
|
+
const RUN_ID_RE = /^[0-9]{13}-[0-9a-f]{8}$/;
|
|
31
|
+
|
|
32
|
+
/** Sortable, collision-safe run id: `<ms since epoch>-<random hex>`. */
|
|
33
|
+
export function newRunId(now = Date.now()): string {
|
|
34
|
+
return `${String(now).padStart(13, '0')}-${crypto.randomBytes(4).toString('hex')}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class RunStore {
|
|
38
|
+
/** Absolute data root; exposed so startup checks can report and test it. */
|
|
39
|
+
readonly root: string;
|
|
40
|
+
private readonly runsDir: string;
|
|
41
|
+
private readonly chatsDir: string;
|
|
42
|
+
/** Serializes appends per run so event order matches emit order. */
|
|
43
|
+
private appendChains = new Map<string, Promise<void>>();
|
|
44
|
+
/** Serializes projects.json rewrites. */
|
|
45
|
+
private projectsChain: Promise<unknown> = Promise.resolve();
|
|
46
|
+
|
|
47
|
+
constructor(root: string = path.join(os.homedir(), '.foreman')) {
|
|
48
|
+
this.root = root;
|
|
49
|
+
this.runsDir = path.join(root, 'runs');
|
|
50
|
+
this.chatsDir = path.join(root, 'chats');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// -- projects -------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
private get projectsFile(): string {
|
|
56
|
+
return path.join(this.root, 'projects.json');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async listProjects(): Promise<Project[]> {
|
|
60
|
+
const raw = await readFile(this.projectsFile, 'utf8').catch(() => null);
|
|
61
|
+
if (!raw) return [];
|
|
62
|
+
try {
|
|
63
|
+
const parsed: unknown = JSON.parse(raw);
|
|
64
|
+
return Array.isArray(parsed) ? (parsed as Project[]) : [];
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Atomically rewrites projects.json through `mutate`; returns its result. */
|
|
71
|
+
private mutateProjects<T>(mutate: (projects: Project[]) => { projects: Project[]; result: T }): Promise<T> {
|
|
72
|
+
const task = this.projectsChain.then(async () => {
|
|
73
|
+
const { projects, result } = mutate(await this.listProjects());
|
|
74
|
+
await mkdir(this.root, { recursive: true });
|
|
75
|
+
const tmp = path.join(this.root, `.projects.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
76
|
+
await writeFile(tmp, JSON.stringify(projects, null, 2));
|
|
77
|
+
await rename(tmp, this.projectsFile);
|
|
78
|
+
return result;
|
|
79
|
+
});
|
|
80
|
+
this.projectsChain = task.catch(() => {});
|
|
81
|
+
return task;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Links a folder as a project. Re-linking an already-linked folder returns
|
|
85
|
+
* the existing project instead of duplicating it. */
|
|
86
|
+
/**
|
|
87
|
+
* Applies a partial change to one project. `provider: null` clears the pin
|
|
88
|
+
* (back to the server default) — distinct from `undefined`, which means
|
|
89
|
+
* "leave it alone", so the settings form can express both.
|
|
90
|
+
*/
|
|
91
|
+
updateProject(
|
|
92
|
+
id: string,
|
|
93
|
+
patch: { name?: string; defaultBudgetUsd?: number; provider?: ProviderRef | null },
|
|
94
|
+
): Promise<Project | null> {
|
|
95
|
+
return this.mutateProjects((projects) => {
|
|
96
|
+
const i = projects.findIndex((p) => p.id === id);
|
|
97
|
+
if (i === -1) return { projects, result: null };
|
|
98
|
+
|
|
99
|
+
const next: Project = { ...projects[i] };
|
|
100
|
+
if (patch.name?.trim()) next.name = patch.name.trim();
|
|
101
|
+
if (typeof patch.defaultBudgetUsd === 'number' && patch.defaultBudgetUsd > 0) {
|
|
102
|
+
next.defaultBudgetUsd = patch.defaultBudgetUsd;
|
|
103
|
+
}
|
|
104
|
+
if (patch.provider === null) {
|
|
105
|
+
delete next.provider;
|
|
106
|
+
// Drop the pre-provider pin too, or clearing would silently fall back
|
|
107
|
+
// to it through providerOf().
|
|
108
|
+
delete next.claudeInstance;
|
|
109
|
+
} else if (patch.provider) {
|
|
110
|
+
next.provider = patch.provider;
|
|
111
|
+
delete next.claudeInstance;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const updated = [...projects];
|
|
115
|
+
updated[i] = next;
|
|
116
|
+
return { projects: updated, result: next };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
addProject(folder: string, name?: string, provider?: ProviderRef): Promise<Project> {
|
|
121
|
+
return this.mutateProjects((projects) => {
|
|
122
|
+
const existing = projects.find((p) => p.folder === folder);
|
|
123
|
+
if (existing) return { projects, result: existing };
|
|
124
|
+
const project: Project = {
|
|
125
|
+
id: `p-${crypto.randomBytes(6).toString('hex')}`,
|
|
126
|
+
name: name?.trim() || path.basename(folder),
|
|
127
|
+
folder,
|
|
128
|
+
createdAt: Date.now(),
|
|
129
|
+
defaultBudgetUsd: 5,
|
|
130
|
+
...(provider ? { provider } : {}),
|
|
131
|
+
};
|
|
132
|
+
return { projects: [...projects, project], result: project };
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Unlinks a project (run history is kept). Returns whether it existed. */
|
|
137
|
+
removeProject(projectId: string): Promise<boolean> {
|
|
138
|
+
return this.mutateProjects((projects) => {
|
|
139
|
+
const rest = projects.filter((p) => p.id !== projectId);
|
|
140
|
+
return { projects: rest, result: rest.length !== projects.length };
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async getProject(projectId: string): Promise<Project | null> {
|
|
145
|
+
return (await this.listProjects()).find((p) => p.id === projectId) ?? null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// -- settings -------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
private get settingsFile(): string {
|
|
151
|
+
return path.join(this.root, 'settings.json');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Reads persisted settings: a global object plus per-project overlays. */
|
|
155
|
+
async readSettings(): Promise<SettingsFile> {
|
|
156
|
+
const raw = await readFile(this.settingsFile, 'utf8').catch(() => null);
|
|
157
|
+
if (!raw) return { global: {}, projects: {} };
|
|
158
|
+
try {
|
|
159
|
+
const parsed = JSON.parse(raw) as Partial<SettingsFile>;
|
|
160
|
+
return { global: parsed.global ?? {}, projects: parsed.projects ?? {} };
|
|
161
|
+
} catch {
|
|
162
|
+
return { global: {}, projects: {} };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Atomically replaces settings.json (serialized like projects.json). */
|
|
167
|
+
writeSettings(settings: SettingsFile): Promise<void> {
|
|
168
|
+
const task = this.projectsChain.then(async () => {
|
|
169
|
+
await mkdir(this.root, { recursive: true });
|
|
170
|
+
const tmp = path.join(this.root, `.settings.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
171
|
+
await writeFile(tmp, JSON.stringify(settings, null, 2));
|
|
172
|
+
await rename(tmp, this.settingsFile);
|
|
173
|
+
});
|
|
174
|
+
this.projectsChain = task.catch(() => {});
|
|
175
|
+
return task;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// -- runs -----------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
private runDir(runId: string): string {
|
|
181
|
+
if (!RUN_ID_RE.test(runId)) throw new Error(`invalid run id: ${runId}`);
|
|
182
|
+
return path.join(this.runsDir, runId);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Creates the run directory and writes initial metadata. */
|
|
186
|
+
async createRun(meta: RunMeta): Promise<void> {
|
|
187
|
+
await mkdir(this.runDir(meta.id), { recursive: true });
|
|
188
|
+
await this.writeMeta(meta);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Atomically replaces meta.json (tmp + rename). */
|
|
192
|
+
async writeMeta(meta: RunMeta): Promise<void> {
|
|
193
|
+
const dir = this.runDir(meta.id);
|
|
194
|
+
const tmp = path.join(dir, `.meta.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
195
|
+
await writeFile(tmp, JSON.stringify(meta, null, 2));
|
|
196
|
+
await rename(tmp, path.join(dir, 'meta.json'));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Appends one event to the run's log. Returns a promise that resolves when
|
|
201
|
+
* the line is on disk; callers may fire-and-forget — order is preserved.
|
|
202
|
+
*/
|
|
203
|
+
append(runId: string, event: ForemanEvent): Promise<void> {
|
|
204
|
+
const file = path.join(this.runDir(runId), 'events.jsonl');
|
|
205
|
+
const prev = this.appendChains.get(runId) ?? Promise.resolve();
|
|
206
|
+
const next = prev.then(() => appendFile(file, JSON.stringify(event) + '\n'));
|
|
207
|
+
// Keep the chain alive even if one append fails; the failure still
|
|
208
|
+
// surfaces to the caller awaiting `next`.
|
|
209
|
+
this.appendChains.set(runId, next.catch(() => {}));
|
|
210
|
+
return next;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async readMeta(runId: string): Promise<RunMeta | null> {
|
|
214
|
+
const raw = await readFile(path.join(this.runDir(runId), 'meta.json'), 'utf8')
|
|
215
|
+
.catch(() => null);
|
|
216
|
+
if (!raw) return null;
|
|
217
|
+
try {
|
|
218
|
+
return JSON.parse(raw) as RunMeta;
|
|
219
|
+
} catch {
|
|
220
|
+
return null; // unreadable meta is treated as missing, never fatal
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Reads the full event log; skips lines that fail to parse (torn writes). */
|
|
225
|
+
async readEvents(runId: string): Promise<ForemanEvent[]> {
|
|
226
|
+
const raw = await readFile(path.join(this.runDir(runId), 'events.jsonl'), 'utf8')
|
|
227
|
+
.catch(() => '');
|
|
228
|
+
const events: ForemanEvent[] = [];
|
|
229
|
+
for (const line of raw.split('\n')) {
|
|
230
|
+
if (!line.trim()) continue;
|
|
231
|
+
try {
|
|
232
|
+
events.push(JSON.parse(line) as ForemanEvent);
|
|
233
|
+
} catch {
|
|
234
|
+
// A torn final line after a crash is expected; drop it.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return events;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Lists all runs, newest first. Reads only meta.json files. */
|
|
241
|
+
async listRuns(): Promise<RunSummary[]> {
|
|
242
|
+
const ids = await readdir(this.runsDir).catch(() => [] as string[]);
|
|
243
|
+
const metas = await Promise.all(
|
|
244
|
+
ids.filter((id) => RUN_ID_RE.test(id)).map((id) => this.readMeta(id)),
|
|
245
|
+
);
|
|
246
|
+
return metas
|
|
247
|
+
.filter((m): m is RunMeta => m !== null)
|
|
248
|
+
.sort((a, b) => b.id.localeCompare(a.id));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// -- chats ----------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A project's planning conversation lives in the same two-file shape as a
|
|
255
|
+
* run: durable meta plus an append-only event log the UI replays. Project
|
|
256
|
+
* ids are generated by this store (`p-<hex>`), but they arrive here from
|
|
257
|
+
* request paths, so the same containment check a run id gets applies.
|
|
258
|
+
*/
|
|
259
|
+
private chatDir(projectId: string): string {
|
|
260
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(projectId)) throw new Error(`invalid project id: ${projectId}`);
|
|
261
|
+
return path.join(this.chatsDir, projectId);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async readChatMeta(projectId: string): Promise<ChatMeta | null> {
|
|
265
|
+
const raw = await readFile(path.join(this.chatDir(projectId), 'meta.json'), 'utf8')
|
|
266
|
+
.catch(() => null);
|
|
267
|
+
if (!raw) return null;
|
|
268
|
+
try {
|
|
269
|
+
return JSON.parse(raw) as ChatMeta;
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Atomically replaces a chat's meta.json, creating the directory if needed. */
|
|
276
|
+
async writeChatMeta(meta: ChatMeta): Promise<void> {
|
|
277
|
+
const dir = this.chatDir(meta.projectId);
|
|
278
|
+
await mkdir(dir, { recursive: true });
|
|
279
|
+
const tmp = path.join(dir, `.meta.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
280
|
+
await writeFile(tmp, JSON.stringify(meta, null, 2));
|
|
281
|
+
await rename(tmp, path.join(dir, 'meta.json'));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Appends one event to a project's chat log, serialized like a run's. */
|
|
285
|
+
appendChat(projectId: string, event: ForemanEvent): Promise<void> {
|
|
286
|
+
const key = `chat:${projectId}`;
|
|
287
|
+
const file = path.join(this.chatDir(projectId), 'events.jsonl');
|
|
288
|
+
const prev = this.appendChains.get(key) ?? Promise.resolve();
|
|
289
|
+
const next = prev
|
|
290
|
+
.then(() => mkdir(this.chatDir(projectId), { recursive: true }))
|
|
291
|
+
.then(() => appendFile(file, JSON.stringify(event) + '\n'));
|
|
292
|
+
this.appendChains.set(key, next.catch(() => {}));
|
|
293
|
+
return next;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Every project id that has a planning conversation on disk. */
|
|
297
|
+
async listChatIds(): Promise<string[]> {
|
|
298
|
+
const names = await readdir(this.chatsDir).catch(() => [] as string[]);
|
|
299
|
+
return names.filter((n) => !n.startsWith('.'));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Reads a chat's full event log; skips lines that fail to parse. */
|
|
303
|
+
async readChatEvents(projectId: string): Promise<ForemanEvent[]> {
|
|
304
|
+
const raw = await readFile(path.join(this.chatDir(projectId), 'events.jsonl'), 'utf8')
|
|
305
|
+
.catch(() => '');
|
|
306
|
+
const events: ForemanEvent[] = [];
|
|
307
|
+
for (const line of raw.split('\n')) {
|
|
308
|
+
if (!line.trim()) continue;
|
|
309
|
+
try {
|
|
310
|
+
events.push(JSON.parse(line) as ForemanEvent);
|
|
311
|
+
} catch {
|
|
312
|
+
// A torn final line after a crash is expected; drop it.
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return events;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Forgets a conversation: the log and the session id both go, so the next
|
|
320
|
+
* message starts a genuinely new session rather than resuming a cleared one.
|
|
321
|
+
*/
|
|
322
|
+
async clearChat(projectId: string): Promise<void> {
|
|
323
|
+
await rm(this.chatDir(projectId), { recursive: true, force: true });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Marks runs left in status "running" by a dead process as interrupted,
|
|
328
|
+
* appending a synthetic `run_finished` so replayed logs terminate cleanly.
|
|
329
|
+
* Returns the ids of swept runs.
|
|
330
|
+
*/
|
|
331
|
+
async sweepOrphans(): Promise<string[]> {
|
|
332
|
+
const swept: string[] = [];
|
|
333
|
+
for (const meta of await this.listRuns()) {
|
|
334
|
+
if (meta.status !== 'running') continue;
|
|
335
|
+
const ended: RunMeta = { ...meta, status: 'interrupted', endedAt: Date.now() };
|
|
336
|
+
await this.append(meta.id, {
|
|
337
|
+
ts: Date.now(),
|
|
338
|
+
event: 'run_finished',
|
|
339
|
+
data: { status: 'interrupted', costUsd: meta.costUsd, reason: 'server restarted' },
|
|
340
|
+
});
|
|
341
|
+
await this.writeMeta(ended);
|
|
342
|
+
swept.push(meta.id);
|
|
343
|
+
}
|
|
344
|
+
return swept;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { isTailscaleIp, parseTailscaleStatus, tailnetFromInterfaces, tailnetUrl } from './tailscale.js';
|
|
4
|
+
|
|
5
|
+
test('isTailscaleIp: only 100.64.0.0/10', () => {
|
|
6
|
+
assert.equal(isTailscaleIp('100.94.221.98'), true);
|
|
7
|
+
assert.equal(isTailscaleIp('100.64.0.1'), true);
|
|
8
|
+
assert.equal(isTailscaleIp('100.127.255.255'), true);
|
|
9
|
+
assert.equal(isTailscaleIp('100.128.0.1'), false);
|
|
10
|
+
assert.equal(isTailscaleIp('100.63.0.1'), false);
|
|
11
|
+
assert.equal(isTailscaleIp('192.168.1.10'), false);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('parseTailscaleStatus: running with an address and a name; stopped is null', () => {
|
|
15
|
+
const t = parseTailscaleStatus({ BackendState: 'Running', Self: { DNSName: 'laptop.tail1234.ts.net.', TailscaleIPs: ['100.94.221.98', 'fd7a::1'] } });
|
|
16
|
+
assert.deepEqual(t, { ip: '100.94.221.98', dnsName: 'laptop.tail1234.ts.net' });
|
|
17
|
+
assert.equal(parseTailscaleStatus({ BackendState: 'Stopped', Self: { TailscaleIPs: ['100.94.221.98'] } }), null);
|
|
18
|
+
assert.equal(parseTailscaleStatus({ BackendState: 'Running', Self: { TailscaleIPs: [] } }), null);
|
|
19
|
+
assert.equal(parseTailscaleStatus(null), null);
|
|
20
|
+
assert.equal(tailnetUrl(t!, 4177), 'http://laptop.tail1234.ts.net:4177');
|
|
21
|
+
assert.equal(tailnetUrl({ ip: '100.1.2.3' }, 4177), 'http://100.1.2.3:4177');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('tailnetFromInterfaces: finds the CGNAT address, ignores loopback and LAN', () => {
|
|
25
|
+
const t = tailnetFromInterfaces({
|
|
26
|
+
lo0: [{ address: '127.0.0.1', family: 'IPv4', internal: true } as never],
|
|
27
|
+
en0: [{ address: '192.168.1.5', family: 'IPv4', internal: false } as never],
|
|
28
|
+
utun4: [{ address: '100.94.221.98', family: 'IPv4', internal: false } as never],
|
|
29
|
+
});
|
|
30
|
+
assert.deepEqual(t, { ip: '100.94.221.98' });
|
|
31
|
+
assert.equal(tailnetFromInterfaces({ en0: [{ address: '192.168.1.5', family: 'IPv4', internal: false } as never] }), null);
|
|
32
|
+
});
|
package/src/tailscale.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is this machine on a tailnet, and what is it called there?
|
|
3
|
+
*
|
|
4
|
+
* Foreman has no login. Listening on every interface would put the dashboard
|
|
5
|
+
* on whatever Wi-Fi the laptop joins; listening on loopback alone leaves the
|
|
6
|
+
* phone out. Tailscale is the middle: a private network of the user's own
|
|
7
|
+
* devices, with a stable name. When it is present Foreman listens on it too,
|
|
8
|
+
* and the links it sends to the phone use that name — the phone can open
|
|
9
|
+
* them from anywhere the tailnet reaches.
|
|
10
|
+
*
|
|
11
|
+
* Detection is read-only and best-effort: the CLI's `status --json` when a
|
|
12
|
+
* CLI exists (PATH, or the Mac app's bundle), else an interface holding an
|
|
13
|
+
* address in Tailscale's 100.64.0.0/10 range. Never an install, never a
|
|
14
|
+
* login — those are the user's, in Tailscale's own UI.
|
|
15
|
+
*/
|
|
16
|
+
import { execFile } from 'node:child_process';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
|
|
19
|
+
export interface Tailnet {
|
|
20
|
+
/** The IPv4 address on the tailnet, e.g. `100.94.221.98`. */
|
|
21
|
+
ip: string;
|
|
22
|
+
/** MagicDNS name without the trailing dot, e.g. `laptop.tail1234.ts.net`. */
|
|
23
|
+
dnsName?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True for addresses in 100.64.0.0/10, the CGNAT range Tailscale hands out. */
|
|
27
|
+
export function isTailscaleIp(ip: string): boolean {
|
|
28
|
+
const m = /^100\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/.exec(ip);
|
|
29
|
+
if (!m) return false;
|
|
30
|
+
const second = Number(m[1]);
|
|
31
|
+
return second >= 64 && second <= 127;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The parts of `tailscale status --json` Foreman reads; null when not running or unusable. */
|
|
35
|
+
export function parseTailscaleStatus(json: unknown): Tailnet | null {
|
|
36
|
+
const d = json as { BackendState?: string; Self?: { DNSName?: string; TailscaleIPs?: string[] } } | null;
|
|
37
|
+
if (!d || d.BackendState !== 'Running') return null;
|
|
38
|
+
const ip = (d.Self?.TailscaleIPs ?? []).find(isTailscaleIp);
|
|
39
|
+
if (!ip) return null;
|
|
40
|
+
const dns = (d.Self?.DNSName ?? '').replace(/\.$/, '');
|
|
41
|
+
return { ip, ...(dns ? { dnsName: dns } : {}) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An interface carrying a tailnet address, when the CLI is not around to ask. */
|
|
45
|
+
export function tailnetFromInterfaces(ifaces: NodeJS.Dict<os.NetworkInterfaceInfo[]> = os.networkInterfaces()): Tailnet | null {
|
|
46
|
+
for (const list of Object.values(ifaces)) {
|
|
47
|
+
for (const i of list ?? []) {
|
|
48
|
+
if (i.family === 'IPv4' && !i.internal && isTailscaleIp(i.address)) return { ip: i.address };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const CLI_CANDIDATES = [
|
|
55
|
+
'tailscale',
|
|
56
|
+
'/Applications/Tailscale.app/Contents/MacOS/Tailscale',
|
|
57
|
+
'C:\\Program Files\\Tailscale\\tailscale.exe',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
function run(cmd: string, args: string[], timeoutMs: number): Promise<string | null> {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => resolve(err ? null : String(stdout)));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The tailnet this machine is on, or null. Read-only; a few seconds at most. */
|
|
67
|
+
export async function detectTailscale(): Promise<Tailnet | null> {
|
|
68
|
+
for (const cmd of CLI_CANDIDATES) {
|
|
69
|
+
const out = await run(cmd, ['status', '--json'], 3_000);
|
|
70
|
+
if (!out) continue;
|
|
71
|
+
try { const t = parseTailscaleStatus(JSON.parse(out)); if (t) return t; } catch { /* not JSON — try the next */ }
|
|
72
|
+
}
|
|
73
|
+
return tailnetFromInterfaces();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `http://laptop.tail1234.ts.net:4177` — the name when there is one, the address otherwise. */
|
|
77
|
+
export function tailnetUrl(t: Tailnet, port: number): string {
|
|
78
|
+
return `http://${t.dnsName ?? t.ip}:${port}`;
|
|
79
|
+
}
|
package/src/title.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mission titles.
|
|
3
|
+
*
|
|
4
|
+
* A brief is a paragraph of instructions; a run list needs a name. Clipping
|
|
5
|
+
* the brief mid-word ("Design a high-end fitness studio bran…") reads as an
|
|
6
|
+
* accident, so once per run Foreman asks the cheapest model for a short label
|
|
7
|
+
* and persists it in run metadata.
|
|
8
|
+
*
|
|
9
|
+
* Three properties matter more than the title itself:
|
|
10
|
+
* - It is never load-bearing. The call is fire-and-forget and every failure
|
|
11
|
+
* path returns null, leaving the UI to fall back to the brief.
|
|
12
|
+
* - It bills the same provider as the mission it names, so a project pinned to
|
|
13
|
+
* its own login does not quietly charge someone else a fraction of a cent.
|
|
14
|
+
* - It costs what it costs, visibly: the caller folds the reported spend into
|
|
15
|
+
* the run's cost rather than hiding it.
|
|
16
|
+
*/
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import { query, type SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
19
|
+
import type { AgentEnv } from './provider.js';
|
|
20
|
+
|
|
21
|
+
/** Cheapest model in the roster — the whole point of the exercise. */
|
|
22
|
+
const TITLE_MODEL = 'haiku';
|
|
23
|
+
|
|
24
|
+
/** Titles longer than this are a summary, not a name. */
|
|
25
|
+
const MAX_TITLE_CHARS = 60;
|
|
26
|
+
|
|
27
|
+
/** A stuck subprocess must not leak; the title is optional, so give up early. */
|
|
28
|
+
// Generous on purpose. Nothing waits on the title, and a slow first call on
|
|
29
|
+
// a cloud gateway (test-4's kimi took 114 s to answer its first turn) is not
|
|
30
|
+
// a reason to leave a run named by its brief for good.
|
|
31
|
+
const TIMEOUT_MS = 150_000;
|
|
32
|
+
|
|
33
|
+
const SYSTEM_PROMPT =
|
|
34
|
+
'You name software missions. You reply with the title and nothing else: no ' +
|
|
35
|
+
'preamble, no quotes, no trailing period, no markdown.';
|
|
36
|
+
|
|
37
|
+
export interface RunTitle {
|
|
38
|
+
title: string;
|
|
39
|
+
/** What the naming call itself cost, for the run's ledger. */
|
|
40
|
+
costUsd: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Collapses model output into a single short line, or null if unusable. */
|
|
44
|
+
function cleanTitle(raw: string): string | null {
|
|
45
|
+
const line = raw.trim().split('\n').find((l) => l.trim()) ?? '';
|
|
46
|
+
const cleaned = line
|
|
47
|
+
.replace(/^["'`*\s]+|["'`*\s.]+$/g, '')
|
|
48
|
+
.replace(/\s+/g, ' ')
|
|
49
|
+
.trim();
|
|
50
|
+
if (!cleaned || cleaned.length < 3) return null;
|
|
51
|
+
return cleaned.length > MAX_TITLE_CHARS
|
|
52
|
+
? cleaned.slice(0, MAX_TITLE_CHARS - 1).trimEnd() + '…'
|
|
53
|
+
: cleaned;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Asks the cheap model to name a mission. Resolves to null on any failure —
|
|
58
|
+
* no auth, no network, a timeout, or a model that answered with prose.
|
|
59
|
+
*
|
|
60
|
+
* Runs with no tools, no setting sources, and a temp cwd: this call must not
|
|
61
|
+
* read the project, and giving it nothing to read is cheaper than trusting it
|
|
62
|
+
* not to.
|
|
63
|
+
*/
|
|
64
|
+
export async function generateRunTitle(
|
|
65
|
+
mission: string,
|
|
66
|
+
agentEnv: AgentEnv,
|
|
67
|
+
): Promise<RunTitle | null> {
|
|
68
|
+
const brief = mission.trim().slice(0, 2000);
|
|
69
|
+
if (!brief) return null;
|
|
70
|
+
|
|
71
|
+
const prompt =
|
|
72
|
+
'Name this mission in 3-7 words — a title someone can pick out of a list, ' +
|
|
73
|
+
'naming the concrete thing being built or changed. Reply with the title only.\n\n' +
|
|
74
|
+
`MISSION:\n${brief}`;
|
|
75
|
+
|
|
76
|
+
let q: ReturnType<typeof query> | undefined;
|
|
77
|
+
let timedOut = false;
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
timedOut = true;
|
|
80
|
+
void (q as AsyncGenerator<SDKMessage> | undefined)?.return?.(undefined as never)
|
|
81
|
+
.catch(() => {});
|
|
82
|
+
}, TIMEOUT_MS);
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
q = query({
|
|
86
|
+
prompt,
|
|
87
|
+
options: {
|
|
88
|
+
model: TITLE_MODEL,
|
|
89
|
+
maxTurns: 1,
|
|
90
|
+
// Everything below strips context this call has no use for. It is not
|
|
91
|
+
// tidiness: the same request with the default harness context loaded
|
|
92
|
+
// (Claude Code's tools plus the account's claude.ai connectors) costs
|
|
93
|
+
// around 100x more than the ~$0.0003 it costs stripped, because the
|
|
94
|
+
// tool definitions dwarf the paragraph being named.
|
|
95
|
+
tools: [],
|
|
96
|
+
allowedTools: [],
|
|
97
|
+
mcpServers: {},
|
|
98
|
+
strictMcpConfig: true,
|
|
99
|
+
settings: { disableClaudeAiConnectors: true },
|
|
100
|
+
// A title is a lookup, not a problem: reasoning tokens here cost more
|
|
101
|
+
// than the answer is worth.
|
|
102
|
+
thinking: { type: 'disabled' },
|
|
103
|
+
// A plain string prompt opts out of the claude_code preset; with no
|
|
104
|
+
// setting sources, the user's CLAUDE.md and plugins stay out too.
|
|
105
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
106
|
+
settingSources: [],
|
|
107
|
+
cwd: os.tmpdir(),
|
|
108
|
+
...agentEnv,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
let text = '';
|
|
113
|
+
let costUsd = 0;
|
|
114
|
+
for await (const msg of q as AsyncIterable<SDKMessage>) {
|
|
115
|
+
const m = msg as Record<string, unknown>;
|
|
116
|
+
if (m.type === 'assistant') {
|
|
117
|
+
const content = (m.message as { content?: Array<Record<string, unknown>> })?.content ?? [];
|
|
118
|
+
for (const b of content) if (b.type === 'text') text += String(b.text ?? '');
|
|
119
|
+
} else if (m.type === 'result') {
|
|
120
|
+
if (typeof m.total_cost_usd === 'number') costUsd = m.total_cost_usd;
|
|
121
|
+
if (m.is_error) { console.warn(`[title] model returned an error: ${String(m.result ?? m.subtype ?? '').slice(0, 200)}`); return null; }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const title = cleanTitle(text);
|
|
126
|
+
if (!title) console.warn(`[title] ${timedOut ? `no answer within ${TIMEOUT_MS / 1000}s` : text.trim() ? `unusable reply: ${JSON.stringify(text.slice(0, 120))}` : 'empty reply'}`);
|
|
127
|
+
return title ? { title, costUsd } : null;
|
|
128
|
+
} catch (e) {
|
|
129
|
+
// A name is never worth failing a mission over — but a silent failure
|
|
130
|
+
// left a run un-named twice with nothing to go on. Say why, once.
|
|
131
|
+
console.warn(`[title] failed: ${(e as Error)?.message ?? e}`);
|
|
132
|
+
return null;
|
|
133
|
+
} finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
await (q as AsyncGenerator<SDKMessage> | undefined)?.return?.(undefined as never)
|
|
136
|
+
.catch(() => {});
|
|
137
|
+
}
|
|
138
|
+
}
|