@blockrun/franklin 3.9.5 → 3.10.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.
@@ -174,6 +174,7 @@ function getToolPatternsSection() {
174
174
  - **Research**: WebSearch for discovery → WebFetch for specific URLs from search results. Don't WebFetch URLs you invented.
175
175
  - **Complex tasks**: Use Agent to spawn sub-agents for 2+ independent research or implementation tasks. Don't do sequentially what can be done in parallel.
176
176
  - **Multiple independent lookups**: Call all tools in a single response. NEVER make sequential calls when parallel calls would work.
177
+ - **Long-running iteration (>20 items)**: Use the **Detach** tool, not turn-by-turn loops. Write a script that iterates and persists a checkpoint file (e.g. \`./.franklin/<task>.checkpoint.json\` with cursor + processedCount), then start it via Detach — \`{ label: "scrape stargazers", command: "node fetch.mjs" }\`. Detach returns a runId immediately and the work continues even if Franklin exits. Inspect with \`franklin task tail <runId> --follow\` / \`task wait <runId>\` / \`task cancel <runId>\`. The agent's job is to design and orchestrate, not to be the for-loop. Pattern fits paginated APIs, batch enrichment, large CSV emit, anything where the loop body is deterministic.
177
178
 
178
179
  # Grounding Before Answering
179
180
  Your training data is frozen in the past. Live-world questions MUST be answered from tool results, not memory.
package/dist/agent/llm.js CHANGED
@@ -14,9 +14,18 @@ function parseTimeoutEnv(name) {
14
14
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
15
15
  }
16
16
  function getModelRequestTimeoutMs() {
17
+ // 180s budget for *time-to-headers* (the gateway flushes SSE headers only
18
+ // once the upstream model emits its first token). Reasoning-class models
19
+ // (zai/glm-*, nemotron *-reasoning, deepseek-r*, gpt-5-codex, anthropic
20
+ // extended-thinking) routinely take 60–120s to first token on cache-cold
21
+ // prompts or when the gateway is under load — the old 45s default cut
22
+ // those off and wasted USDC on retries that hit the same wall. 180s is
23
+ // generous enough for any realistic first-token latency, still bounded
24
+ // enough that genuinely dead requests surface within ~6 min after the
25
+ // single timeout retry.
17
26
  return (parseTimeoutEnv('FRANKLIN_MODEL_REQUEST_TIMEOUT_MS') ??
18
27
  parseTimeoutEnv('FRANKLIN_MODEL_IDLE_TIMEOUT_MS') ??
19
- 45_000);
28
+ 180_000);
20
29
  }
21
30
  function getModelStreamIdleTimeoutMs() {
22
31
  return (parseTimeoutEnv('FRANKLIN_MODEL_STREAM_IDLE_TIMEOUT_MS') ??
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `franklin task` CLI surface — human-facing operations on detached background
3
+ * tasks. Mirrors the on-disk shape under `~/.franklin/tasks/<runId>/` that the
4
+ * runner / store layers maintain. Subcommands grow incrementally over T10–T13:
5
+ * - list : recent tasks, newest first
6
+ * - tail : print log + status; --follow polls until terminal
7
+ * - cancel : SIGTERM the runner pid
8
+ * - wait : block until terminal, exit 0/1/2 by outcome
9
+ */
10
+ import { Command } from 'commander';
11
+ export declare function buildTaskCommand(): Command;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `franklin task` CLI surface — human-facing operations on detached background
3
+ * tasks. Mirrors the on-disk shape under `~/.franklin/tasks/<runId>/` that the
4
+ * runner / store layers maintain. Subcommands grow incrementally over T10–T13:
5
+ * - list : recent tasks, newest first
6
+ * - tail : print log + status; --follow polls until terminal
7
+ * - cancel : SIGTERM the runner pid
8
+ * - wait : block until terminal, exit 0/1/2 by outcome
9
+ */
10
+ import fs from 'node:fs';
11
+ import { Command } from 'commander';
12
+ import { listTasks, readTaskMeta } from '../tasks/store.js';
13
+ import { reconcileLostTasks } from '../tasks/lost-detection.js';
14
+ import { taskLogPath } from '../tasks/paths.js';
15
+ import { isTerminalTaskStatus } from '../tasks/types.js';
16
+ function fmtAge(ms) {
17
+ const s = Math.floor(ms / 1000);
18
+ if (s < 60)
19
+ return `${s}s`;
20
+ const m = Math.floor(s / 60);
21
+ if (m < 60)
22
+ return `${m}m`;
23
+ return `${Math.floor(m / 60)}h${m % 60}m`;
24
+ }
25
+ export function buildTaskCommand() {
26
+ const cmd = new Command('task').description('Manage long-running detached tasks');
27
+ cmd
28
+ .command('list')
29
+ .description('List recent tasks (newest first)')
30
+ .action(() => {
31
+ reconcileLostTasks();
32
+ const tasks = listTasks();
33
+ if (tasks.length === 0) {
34
+ console.log('No tasks. Start one via the Task agent tool.');
35
+ return;
36
+ }
37
+ const now = Date.now();
38
+ for (const t of tasks) {
39
+ const age = fmtAge(now - (t.lastEventAt ?? t.createdAt));
40
+ console.log(`${t.runId} ${t.status.padEnd(10)} ${age.padStart(5)} ${t.label}`);
41
+ }
42
+ });
43
+ cmd
44
+ .command('tail <runId>')
45
+ .description('Print log + current status for a task')
46
+ .option('-f, --follow', 'Poll until task reaches terminal state')
47
+ .action(async (runId, opts) => {
48
+ const meta0 = readTaskMeta(runId);
49
+ if (!meta0) {
50
+ console.error(`No task: ${runId}`);
51
+ process.exit(1);
52
+ }
53
+ let printed = 0;
54
+ const printNew = () => {
55
+ try {
56
+ const buf = fs.readFileSync(taskLogPath(runId));
57
+ if (buf.length > printed) {
58
+ process.stdout.write(buf.subarray(printed));
59
+ printed = buf.length;
60
+ }
61
+ }
62
+ catch {
63
+ /* log not yet written */
64
+ }
65
+ };
66
+ printNew();
67
+ if (opts.follow) {
68
+ while (true) {
69
+ await new Promise((r) => setTimeout(r, 1000));
70
+ printNew();
71
+ const meta = readTaskMeta(runId);
72
+ if (meta && isTerminalTaskStatus(meta.status))
73
+ break;
74
+ }
75
+ }
76
+ const meta = readTaskMeta(runId);
77
+ if (meta) {
78
+ console.log(`\n--- ${meta.status} ---`);
79
+ if (meta.terminalSummary)
80
+ console.log(meta.terminalSummary);
81
+ }
82
+ });
83
+ cmd
84
+ .command('wait <runId>')
85
+ .description('Block until task reaches terminal state, then exit')
86
+ .option('--timeout <ms>', 'Max wait, default 30 minutes', '1800000')
87
+ .action(async (runId, opts) => {
88
+ const cap = parseInt(opts.timeout, 10);
89
+ const t0 = Date.now();
90
+ while (true) {
91
+ const meta = readTaskMeta(runId);
92
+ if (!meta) {
93
+ console.error(`No task: ${runId}`);
94
+ process.exit(1);
95
+ }
96
+ if (isTerminalTaskStatus(meta.status)) {
97
+ console.log(`${meta.status}: ${meta.terminalSummary ?? ''}`);
98
+ process.exit(meta.status === 'succeeded' ? 0 : 1);
99
+ }
100
+ if (Date.now() - t0 > cap) {
101
+ console.error(`Timed out after ${cap}ms; task still ${meta.status}.`);
102
+ process.exit(2);
103
+ }
104
+ await new Promise((r) => setTimeout(r, 1000));
105
+ }
106
+ });
107
+ cmd
108
+ .command('cancel <runId>')
109
+ .description('Cancel a running task (SIGTERM to runner)')
110
+ .action((runId) => {
111
+ const meta = readTaskMeta(runId);
112
+ if (!meta) {
113
+ console.error(`No task: ${runId}`);
114
+ process.exit(1);
115
+ }
116
+ if (isTerminalTaskStatus(meta.status)) {
117
+ console.log(`Task already ${meta.status}.`);
118
+ return;
119
+ }
120
+ if (typeof meta.pid !== 'number') {
121
+ console.error('Task has no recorded pid (likely still queued).');
122
+ process.exit(1);
123
+ }
124
+ try {
125
+ process.kill(meta.pid, 'SIGTERM');
126
+ console.log(`SIGTERM sent to ${meta.pid}.`);
127
+ }
128
+ catch (err) {
129
+ console.error(`Could not signal pid ${meta.pid}: ${err.message}`);
130
+ process.exit(1);
131
+ }
132
+ });
133
+ return cmd;
134
+ }
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ import { daemonCommand } from './commands/daemon.js';
23
23
  import { initCommand } from './commands/init.js';
24
24
  import { uninitCommand } from './commands/uninit.js';
25
25
  import { proxyCommand } from './commands/proxy.js';
26
+ import { buildTaskCommand } from './commands/task.js';
26
27
  import { VERSION as version } from './config.js';
27
28
  const program = new Command();
28
29
  program
@@ -215,6 +216,21 @@ program
215
216
  const { listAvailablePlugins } = await import('./commands/plugin.js');
216
217
  listAvailablePlugins();
217
218
  });
219
+ // `franklin task <subcmd>` — human-facing CLI for detached background tasks.
220
+ // Defined in src/commands/task.ts; subcommands: list, tail, cancel, wait.
221
+ program.addCommand(buildTaskCommand());
222
+ // Hidden internal subcommand — invoked by startDetachedTask via spawn(detached).
223
+ // The underscore prefix signals "not for humans"; we still register it via
224
+ // commander so exit codes and arg parsing stay consistent with the rest of
225
+ // the CLI.
226
+ program
227
+ .command('_task-runner <runId>')
228
+ .description('(internal) execute a detached task by runId')
229
+ .action(async (runId) => {
230
+ const { runDetachedTask } = await import('./tasks/runner.js');
231
+ const code = await runDetachedTask(runId);
232
+ process.exit(code);
233
+ });
218
234
  // Default action: if no subcommand given, run 'start'
219
235
  const args = process.argv.slice(2);
220
236
  const firstArg = args[0];
@@ -41,7 +41,13 @@ function log(...args) {
41
41
  catch { /* ignore */ }
42
42
  }
43
43
  const DEFAULT_MAX_TOKENS = 4096;
44
- const DEFAULT_PROXY_REQUEST_TIMEOUT_MS = 45_000;
44
+ // 180s budget for *time-to-headers* — reasoning-class models (zai/glm-*,
45
+ // nemotron *-reasoning, deepseek-r*, gpt-5-codex, anthropic extended-thinking)
46
+ // routinely take 60–120s to first token on cache-cold prompts or busy
47
+ // gateways. The old 45s default cut those off and the proxy returned a
48
+ // failed response that downstream agents (Cline, Claude Desktop, etc.) had
49
+ // to retry blindly.
50
+ const DEFAULT_PROXY_REQUEST_TIMEOUT_MS = 180_000;
45
51
  const DEFAULT_PROXY_STREAM_TIMEOUT_MS = 5 * 60 * 1000;
46
52
  function parseTimeoutEnv(name, fallback) {
47
53
  const raw = process.env[name];
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Lost-task detection.
3
+ *
4
+ * For every task currently in `running` or `queued`, check whether its recorded
5
+ * pid is still alive via `process.kill(pid, 0)`. If the pid is gone, the
6
+ * runner crashed or was killed externally; flip status to `lost` so observers
7
+ * (CLI list, agent prompt) stop misreporting it as in-flight.
8
+ *
9
+ * EPERM means the pid exists but we don't have permission to signal it —
10
+ * treat that as alive. ESRCH (or anything else) means dead.
11
+ *
12
+ * Best-effort: PID reuse can lie. v3.10's contract is "lazy reconciliation
13
+ * on `task list`"; v3.11 may add a pidStartTime cross-check.
14
+ */
15
+ export declare function reconcileLostTasks(now?: number): number;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Lost-task detection.
3
+ *
4
+ * For every task currently in `running` or `queued`, check whether its recorded
5
+ * pid is still alive via `process.kill(pid, 0)`. If the pid is gone, the
6
+ * runner crashed or was killed externally; flip status to `lost` so observers
7
+ * (CLI list, agent prompt) stop misreporting it as in-flight.
8
+ *
9
+ * EPERM means the pid exists but we don't have permission to signal it —
10
+ * treat that as alive. ESRCH (or anything else) means dead.
11
+ *
12
+ * Best-effort: PID reuse can lie. v3.10's contract is "lazy reconciliation
13
+ * on `task list`"; v3.11 may add a pidStartTime cross-check.
14
+ */
15
+ import { listTasks, applyEvent } from './store.js';
16
+ function isPidAlive(pid) {
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ }
21
+ catch (err) {
22
+ // EPERM means it exists but we can't signal it — still alive.
23
+ return err.code === 'EPERM';
24
+ }
25
+ }
26
+ export function reconcileLostTasks(now = Date.now()) {
27
+ let n = 0;
28
+ for (const t of listTasks()) {
29
+ if (t.status !== 'running' && t.status !== 'queued')
30
+ continue;
31
+ if (typeof t.pid !== 'number')
32
+ continue;
33
+ if (isPidAlive(t.pid))
34
+ continue;
35
+ try {
36
+ applyEvent(t.runId, {
37
+ at: now,
38
+ kind: 'lost',
39
+ summary: 'Backing process not found — task may have been killed externally.',
40
+ });
41
+ n++;
42
+ }
43
+ catch (err) {
44
+ // Meta could vanish mid-reconcile (e.g. the task dir was deleted out from
45
+ // under us) — log and continue with the next task. One bad task should
46
+ // not abort the whole sweep.
47
+ process.stderr.write(`[franklin] reconcileLostTasks: skipping ${t.runId}: ${err.message}\n`);
48
+ }
49
+ }
50
+ return n;
51
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Per-task on-disk layout under $FRANKLIN_HOME/tasks/<runId>/.
3
+ * meta.json — single TaskRecord, atomically rewritten
4
+ * events.jsonl — append-only event log
5
+ * log.txt — child process stdout/stderr
6
+ */
7
+ export declare function getTasksDir(): string;
8
+ export declare function getTaskDir(runId: string): string;
9
+ export declare function ensureTaskDir(runId: string): string;
10
+ export declare function taskMetaPath(runId: string): string;
11
+ export declare function taskEventsPath(runId: string): string;
12
+ export declare function taskLogPath(runId: string): string;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Per-task on-disk layout under $FRANKLIN_HOME/tasks/<runId>/.
3
+ * meta.json — single TaskRecord, atomically rewritten
4
+ * events.jsonl — append-only event log
5
+ * log.txt — child process stdout/stderr
6
+ */
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ function franklinHome() {
11
+ return process.env.FRANKLIN_HOME || path.join(os.homedir(), '.franklin');
12
+ }
13
+ export function getTasksDir() {
14
+ return path.join(franklinHome(), 'tasks');
15
+ }
16
+ export function getTaskDir(runId) {
17
+ return path.join(getTasksDir(), runId);
18
+ }
19
+ export function ensureTaskDir(runId) {
20
+ const dir = getTaskDir(runId);
21
+ fs.mkdirSync(dir, { recursive: true });
22
+ return dir;
23
+ }
24
+ export function taskMetaPath(runId) {
25
+ return path.join(getTaskDir(runId), 'meta.json');
26
+ }
27
+ export function taskEventsPath(runId) {
28
+ return path.join(getTaskDir(runId), 'events.jsonl');
29
+ }
30
+ export function taskLogPath(runId) {
31
+ return path.join(getTaskDir(runId), 'log.txt');
32
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Detached task runner. The hidden `_task-runner <runId>` subcommand of the
3
+ * `franklin` CLI dispatches into this module, which is what actually executes
4
+ * the user's command in the detached child process.
5
+ *
6
+ * Lifecycle (per task):
7
+ * 1. Read meta.json. Bail with exit code 2 if it's gone.
8
+ * 2. Open log.txt for append, record our own pid + status=running, emit
9
+ * a `running` event.
10
+ * 3. Spawn `bash -lc <command>` with stdout/stderr piped to log.txt.
11
+ * 4. Heartbeat every 5s: just refresh meta.lastEventAt so observers can see
12
+ * "still going."
13
+ * 5. On child exit (or spawn error), close the log fd, finalize meta with
14
+ * exitCode + status (`succeeded` if 0, `failed` otherwise), emit a
15
+ * terminal event whose summary is the last 500 chars of log.
16
+ *
17
+ * Defensive style: we re-read meta inside the heartbeat and on exit because
18
+ * a concurrent `franklin task cancel` (or external `rm -rf`) can vanish the
19
+ * task dir mid-flight. Every fs operation is best-effort.
20
+ */
21
+ export declare function runDetachedTask(runId: string): Promise<number>;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Detached task runner. The hidden `_task-runner <runId>` subcommand of the
3
+ * `franklin` CLI dispatches into this module, which is what actually executes
4
+ * the user's command in the detached child process.
5
+ *
6
+ * Lifecycle (per task):
7
+ * 1. Read meta.json. Bail with exit code 2 if it's gone.
8
+ * 2. Open log.txt for append, record our own pid + status=running, emit
9
+ * a `running` event.
10
+ * 3. Spawn `bash -lc <command>` with stdout/stderr piped to log.txt.
11
+ * 4. Heartbeat every 5s: just refresh meta.lastEventAt so observers can see
12
+ * "still going."
13
+ * 5. On child exit (or spawn error), close the log fd, finalize meta with
14
+ * exitCode + status (`succeeded` if 0, `failed` otherwise), emit a
15
+ * terminal event whose summary is the last 500 chars of log.
16
+ *
17
+ * Defensive style: we re-read meta inside the heartbeat and on exit because
18
+ * a concurrent `franklin task cancel` (or external `rm -rf`) can vanish the
19
+ * task dir mid-flight. Every fs operation is best-effort.
20
+ */
21
+ import { spawn } from 'node:child_process';
22
+ import fs from 'node:fs';
23
+ import { readTaskMeta, applyEvent, writeTaskMeta } from './store.js';
24
+ import { taskLogPath, ensureTaskDir } from './paths.js';
25
+ const HEARTBEAT_MS = 5_000;
26
+ const TAIL_BYTES = 500;
27
+ function safeCloseFd(fd) {
28
+ try {
29
+ fs.closeSync(fd);
30
+ }
31
+ catch {
32
+ /* already closed */
33
+ }
34
+ }
35
+ function readLogTail(runId) {
36
+ try {
37
+ const buf = fs.readFileSync(taskLogPath(runId), 'utf-8');
38
+ return buf.slice(-TAIL_BYTES).replace(/\s+/g, ' ').trim();
39
+ }
40
+ catch {
41
+ return '';
42
+ }
43
+ }
44
+ export async function runDetachedTask(runId) {
45
+ const meta = readTaskMeta(runId);
46
+ if (!meta) {
47
+ process.stderr.write(`runner: no task ${runId}\n`);
48
+ return 2;
49
+ }
50
+ ensureTaskDir(runId);
51
+ const logFd = fs.openSync(taskLogPath(runId), 'a');
52
+ let logFdClosed = false;
53
+ const closeLog = () => {
54
+ if (logFdClosed)
55
+ return;
56
+ logFdClosed = true;
57
+ safeCloseFd(logFd);
58
+ };
59
+ const startedAt = Date.now();
60
+ writeTaskMeta({
61
+ ...meta,
62
+ pid: process.pid,
63
+ status: 'running',
64
+ startedAt,
65
+ lastEventAt: startedAt,
66
+ });
67
+ applyEvent(runId, { at: startedAt, kind: 'running', summary: 'runner started' });
68
+ // `finalized` guards against the rare case where the heartbeat timer
69
+ // already fired but its callback is still on the event-loop queue at
70
+ // the moment finalize() runs — without this flag, a heartbeat write
71
+ // could land *after* the terminal event and clobber lastEventAt /
72
+ // status. We flip it before clearInterval so any pending callback
73
+ // bails on its first line.
74
+ let finalized = false;
75
+ // Heartbeat: every 5s while child is alive, refresh lastEventAt so
76
+ // observers see "still going." If the meta has been deleted out from
77
+ // under us (someone rm'd the task dir), skip silently — no need to
78
+ // re-create a stub.
79
+ const heartbeat = setInterval(() => {
80
+ if (finalized)
81
+ return;
82
+ const cur = readTaskMeta(runId);
83
+ if (!cur)
84
+ return;
85
+ try {
86
+ writeTaskMeta({ ...cur, lastEventAt: Date.now() });
87
+ }
88
+ catch (err) {
89
+ process.stderr.write(`[franklin] runner heartbeat: ${err.message}\n`);
90
+ }
91
+ }, HEARTBEAT_MS);
92
+ // Best-effort finalize. Used by both the normal exit path and the spawn
93
+ // error path. Always closes the log fd and clears the heartbeat.
94
+ // If `finalized` is already true (cancel path beat us to it), bail —
95
+ // we would otherwise overwrite the on-disk `cancelled` terminal state
96
+ // with `failed` after `child.kill('SIGTERM')` causes child.on('exit').
97
+ const finalize = (exitCode, status, fallbackSummary) => {
98
+ if (finalized)
99
+ return;
100
+ finalized = true;
101
+ clearInterval(heartbeat);
102
+ closeLog();
103
+ const endedAt = Date.now();
104
+ const tail = readLogTail(runId);
105
+ const cur = readTaskMeta(runId);
106
+ if (cur) {
107
+ try {
108
+ writeTaskMeta({ ...cur, exitCode });
109
+ }
110
+ catch (err) {
111
+ process.stderr.write(`[franklin] runner finalize writeTaskMeta: ${err.message}\n`);
112
+ }
113
+ try {
114
+ applyEvent(runId, {
115
+ at: endedAt,
116
+ kind: status,
117
+ summary: tail || fallbackSummary,
118
+ });
119
+ }
120
+ catch (err) {
121
+ process.stderr.write(`[franklin] runner finalize applyEvent: ${err.message}\n`);
122
+ }
123
+ }
124
+ else {
125
+ // Meta vanished mid-run. Nothing to finalize. Surface for ops, exit clean.
126
+ process.stderr.write(`[franklin] runner: meta for ${runId} disappeared before finalize\n`);
127
+ }
128
+ };
129
+ const child = spawn('bash', ['-lc', meta.command], {
130
+ cwd: meta.workingDir,
131
+ stdio: ['ignore', logFd, logFd],
132
+ env: { ...process.env, FRANKLIN_TASK_RUN_ID: runId },
133
+ });
134
+ // Cancel path: parent CLI sends SIGTERM (or user hits Ctrl-C). We must
135
+ // (a) flip `finalized` BEFORE the soon-to-fire child.exit handler runs so
136
+ // it short-circuits and doesn't write status=failed,
137
+ // (b) clear the heartbeat for the same reason,
138
+ // (c) kill the child (SIGTERM) so the bash process actually dies,
139
+ // (d) applyEvent('cancelled') so the on-disk terminal state is correct,
140
+ // (e) close the log fd,
141
+ // (f) exit 130 (the canonical Ctrl-C / SIGTERM exit code) on a small delay
142
+ // so any in-flight fs writes flush.
143
+ const onSignal = () => {
144
+ if (finalized)
145
+ return;
146
+ finalized = true;
147
+ clearInterval(heartbeat);
148
+ try {
149
+ child.kill('SIGTERM');
150
+ }
151
+ catch {
152
+ /* child may already be gone */
153
+ }
154
+ closeLog();
155
+ try {
156
+ applyEvent(runId, {
157
+ at: Date.now(),
158
+ kind: 'cancelled',
159
+ summary: 'Cancelled via SIGTERM',
160
+ });
161
+ }
162
+ catch (err) {
163
+ process.stderr.write(`[franklin] runner cancel applyEvent: ${err.message}\n`);
164
+ }
165
+ setTimeout(() => process.exit(130), 500);
166
+ };
167
+ process.on('SIGTERM', onSignal);
168
+ process.on('SIGINT', onSignal);
169
+ return await new Promise((resolve) => {
170
+ let resolved = false;
171
+ const settle = (code) => {
172
+ if (resolved)
173
+ return;
174
+ resolved = true;
175
+ resolve(code);
176
+ };
177
+ child.on('error', (err) => {
178
+ // Spawn itself failed — bash not on $PATH, EACCES, etc. Make sure we
179
+ // close the log fd, finalize the task, and exit.
180
+ const exitCode = 1;
181
+ finalize(exitCode, 'failed', `spawn error: ${err.message}`);
182
+ settle(exitCode);
183
+ });
184
+ child.on('exit', (code, signal) => {
185
+ const exitCode = typeof code === 'number' ? code : signal ? 128 : 1;
186
+ const status = exitCode === 0 ? 'succeeded' : 'failed';
187
+ finalize(exitCode, status, status === 'succeeded' ? 'completed' : `exited with code ${exitCode}`);
188
+ settle(exitCode);
189
+ });
190
+ });
191
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Public spawn surface for the detached task subsystem.
3
+ *
4
+ * `startDetachedTask` is the synchronous entry point used by the `Task`
5
+ * agent tool and by `franklin task` callers. It writes a queued
6
+ * TaskRecord to disk, opens log.txt for stdout/stderr capture, then
7
+ * spawns `franklin _task-runner <runId>` with `detached: true` and
8
+ * unrefs the child so this process can exit without waiting on the
9
+ * task. The runner subprocess takes over from there: it spawns the
10
+ * actual user command, drives heartbeats, and finalizes meta on exit.
11
+ *
12
+ * Performance contract: startDetachedTask must return in <250ms. That
13
+ * is enforced by the integration test in test/local.mjs and is the
14
+ * reason all I/O here is sync — we want one fs write + one spawn, not
15
+ * an async chain that could be interrupted by a slow microtask.
16
+ *
17
+ * CLI path resolution (in priority order):
18
+ * 1. process.env.FRANKLIN_CLI_PATH — escape hatch for tests / dev.
19
+ * 2. <cwd>/dist/index.js — the published bundle's entry point.
20
+ */
21
+ export interface StartDetachedTaskInput {
22
+ label: string;
23
+ command: string;
24
+ workingDir: string;
25
+ }
26
+ export declare function startDetachedTask(input: StartDetachedTaskInput): string;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Public spawn surface for the detached task subsystem.
3
+ *
4
+ * `startDetachedTask` is the synchronous entry point used by the `Task`
5
+ * agent tool and by `franklin task` callers. It writes a queued
6
+ * TaskRecord to disk, opens log.txt for stdout/stderr capture, then
7
+ * spawns `franklin _task-runner <runId>` with `detached: true` and
8
+ * unrefs the child so this process can exit without waiting on the
9
+ * task. The runner subprocess takes over from there: it spawns the
10
+ * actual user command, drives heartbeats, and finalizes meta on exit.
11
+ *
12
+ * Performance contract: startDetachedTask must return in <250ms. That
13
+ * is enforced by the integration test in test/local.mjs and is the
14
+ * reason all I/O here is sync — we want one fs write + one spawn, not
15
+ * an async chain that could be interrupted by a slow microtask.
16
+ *
17
+ * CLI path resolution (in priority order):
18
+ * 1. process.env.FRANKLIN_CLI_PATH — escape hatch for tests / dev.
19
+ * 2. <cwd>/dist/index.js — the published bundle's entry point.
20
+ */
21
+ import { spawn } from 'node:child_process';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { randomUUID } from 'node:crypto';
25
+ import { writeTaskMeta } from './store.js';
26
+ import { taskLogPath, ensureTaskDir } from './paths.js';
27
+ function resolveCliPath() {
28
+ const fromEnv = process.env.FRANKLIN_CLI_PATH;
29
+ if (fromEnv && fromEnv.length > 0)
30
+ return fromEnv;
31
+ return path.resolve(process.cwd(), 'dist', 'index.js');
32
+ }
33
+ function generateRunId() {
34
+ return `t_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
35
+ }
36
+ export function startDetachedTask(input) {
37
+ const runId = generateRunId();
38
+ const now = Date.now();
39
+ const record = {
40
+ runId,
41
+ runtime: 'detached-bash',
42
+ label: input.label,
43
+ command: input.command,
44
+ workingDir: input.workingDir,
45
+ status: 'queued',
46
+ createdAt: now,
47
+ };
48
+ writeTaskMeta(record);
49
+ ensureTaskDir(runId);
50
+ const cliPath = resolveCliPath();
51
+ const logFd = fs.openSync(taskLogPath(runId), 'a');
52
+ // detached + unref + ignore stdin = parent can exit immediately while
53
+ // the child keeps running. The runner reopens its own log handles via
54
+ // the inherited stdout/stderr fds, so we close ours after spawn returns.
55
+ const child = spawn(process.execPath, [cliPath, '_task-runner', runId], {
56
+ cwd: input.workingDir,
57
+ detached: true,
58
+ stdio: ['ignore', logFd, logFd],
59
+ env: { ...process.env, FRANKLIN_TASK_RUN_ID: runId },
60
+ });
61
+ child.unref();
62
+ // The child has duped the fd; closing ours frees the parent's slot.
63
+ // Surface unexpected errors instead of swallowing — a leaked fd here
64
+ // is rare but worth knowing about.
65
+ try {
66
+ fs.closeSync(logFd);
67
+ }
68
+ catch (err) {
69
+ process.stderr.write(`[franklin] startDetachedTask: closing log fd failed: ${err.message}\n`);
70
+ }
71
+ return runId;
72
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Task persistence: meta.json (single record) + events.jsonl (append-only log).
3
+ *
4
+ * Concurrency contract: applyEvent does a read-modify-write on meta.json. It
5
+ * is safe to call from a single writer per task — by convention, that writer
6
+ * is the _task-runner subprocess. CLI commands that need to influence a
7
+ * running task (e.g. `franklin task cancel`) MUST signal the runner pid
8
+ * (SIGTERM) rather than calling applyEvent directly, otherwise the two
9
+ * writers race and one update is silently lost. Lost-task reconciliation
10
+ * is an exception — it runs only when the runner is provably dead, so
11
+ * there is no second writer to race with.
12
+ *
13
+ * Atomicity: writeTaskMeta uses tmp + rename; readers see either old or new
14
+ * meta, never partial. appendTaskEvent relies on POSIX O_APPEND + PIPE_BUF
15
+ * atomicity (~4096 bytes); summaries should stay short. readTaskEvents is
16
+ * tolerant of a torn last line.
17
+ */
18
+ import type { TaskRecord, TaskEventRecord } from './types.js';
19
+ export declare function writeTaskMeta(record: TaskRecord): void;
20
+ export declare function readTaskMeta(runId: string): TaskRecord | null;
21
+ export declare function appendTaskEvent(runId: string, event: TaskEventRecord): void;
22
+ export declare function readTaskEvents(runId: string): TaskEventRecord[];
23
+ export declare function applyEvent(runId: string, event: TaskEventRecord): TaskRecord;
24
+ export declare function listTasks(): TaskRecord[];
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Task persistence: meta.json (single record) + events.jsonl (append-only log).
3
+ *
4
+ * Concurrency contract: applyEvent does a read-modify-write on meta.json. It
5
+ * is safe to call from a single writer per task — by convention, that writer
6
+ * is the _task-runner subprocess. CLI commands that need to influence a
7
+ * running task (e.g. `franklin task cancel`) MUST signal the runner pid
8
+ * (SIGTERM) rather than calling applyEvent directly, otherwise the two
9
+ * writers race and one update is silently lost. Lost-task reconciliation
10
+ * is an exception — it runs only when the runner is provably dead, so
11
+ * there is no second writer to race with.
12
+ *
13
+ * Atomicity: writeTaskMeta uses tmp + rename; readers see either old or new
14
+ * meta, never partial. appendTaskEvent relies on POSIX O_APPEND + PIPE_BUF
15
+ * atomicity (~4096 bytes); summaries should stay short. readTaskEvents is
16
+ * tolerant of a torn last line.
17
+ */
18
+ import fs from 'node:fs';
19
+ import { ensureTaskDir, taskMetaPath, taskEventsPath, getTasksDir, } from './paths.js';
20
+ export function writeTaskMeta(record) {
21
+ ensureTaskDir(record.runId);
22
+ const target = taskMetaPath(record.runId);
23
+ const tmp = `${target}.tmp`;
24
+ fs.writeFileSync(tmp, JSON.stringify(record, null, 2));
25
+ try {
26
+ fs.renameSync(tmp, target);
27
+ }
28
+ catch (err) {
29
+ try {
30
+ fs.unlinkSync(tmp);
31
+ }
32
+ catch { /* may not exist */ }
33
+ throw err;
34
+ }
35
+ }
36
+ export function readTaskMeta(runId) {
37
+ let raw;
38
+ try {
39
+ raw = fs.readFileSync(taskMetaPath(runId), 'utf-8');
40
+ }
41
+ catch (err) {
42
+ if (err.code === 'ENOENT')
43
+ return null;
44
+ // Surface unexpected I/O errors instead of pretending the task doesn't exist.
45
+ throw err;
46
+ }
47
+ try {
48
+ return JSON.parse(raw);
49
+ }
50
+ catch (err) {
51
+ process.stderr.write(`[franklin] meta.json corrupt for ${runId}: ${err.message}\n`);
52
+ return null;
53
+ }
54
+ }
55
+ export function appendTaskEvent(runId, event) {
56
+ ensureTaskDir(runId);
57
+ fs.appendFileSync(taskEventsPath(runId), JSON.stringify(event) + '\n');
58
+ }
59
+ export function readTaskEvents(runId) {
60
+ let raw;
61
+ try {
62
+ raw = fs.readFileSync(taskEventsPath(runId), 'utf-8');
63
+ }
64
+ catch (err) {
65
+ if (err.code === 'ENOENT')
66
+ return [];
67
+ throw err;
68
+ }
69
+ // Per-line tolerance: a torn last line (concurrent appendFileSync over PIPE_BUF)
70
+ // would otherwise discard the whole log. Mirror storage.ts:loadSessionHistory.
71
+ const out = [];
72
+ for (const line of raw.split('\n')) {
73
+ if (!line.trim())
74
+ continue;
75
+ try {
76
+ out.push(JSON.parse(line));
77
+ }
78
+ catch { /* skip torn / corrupt line */ }
79
+ }
80
+ return out;
81
+ }
82
+ export function applyEvent(runId, event) {
83
+ const cur = readTaskMeta(runId);
84
+ if (!cur)
85
+ throw new Error(`applyEvent: no task ${runId}`);
86
+ const next = { ...cur };
87
+ next.lastEventAt = event.at;
88
+ if (event.summary !== undefined)
89
+ next.progressSummary = event.summary;
90
+ if (event.kind === 'running' && next.status === 'queued') {
91
+ next.status = 'running';
92
+ next.startedAt = event.at;
93
+ }
94
+ else if (event.kind !== 'progress' && event.kind !== 'running') {
95
+ // event.kind is now narrowed to terminal statuses
96
+ next.status = event.kind;
97
+ next.endedAt = event.at;
98
+ if (event.summary !== undefined)
99
+ next.terminalSummary = event.summary;
100
+ }
101
+ appendTaskEvent(runId, event);
102
+ writeTaskMeta(next);
103
+ return next;
104
+ }
105
+ export function listTasks() {
106
+ let entries;
107
+ try {
108
+ entries = fs.readdirSync(getTasksDir(), { withFileTypes: true });
109
+ }
110
+ catch {
111
+ return [];
112
+ }
113
+ const out = [];
114
+ for (const ent of entries) {
115
+ // Skip junk like .DS_Store — only real per-task subdirectories are valid.
116
+ if (!ent.isDirectory())
117
+ continue;
118
+ const meta = readTaskMeta(ent.name);
119
+ if (meta)
120
+ out.push(meta);
121
+ }
122
+ out.sort((a, b) => b.createdAt - a.createdAt);
123
+ return out;
124
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Task subsystem types. Mirrors openclaw/openclaw src/tasks/task-registry.types.ts
3
+ * with channel/delivery fields stripped — Franklin is CLI-first single-user.
4
+ */
5
+ export type TaskStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'timed_out' | 'cancelled' | 'lost';
6
+ export type TaskRuntime = 'detached-bash';
7
+ export type TaskTerminalOutcome = 'succeeded' | 'blocked';
8
+ export type TaskEventKind = Exclude<TaskStatus, 'queued'> | 'progress';
9
+ export interface TaskEventRecord {
10
+ at: number;
11
+ kind: TaskEventKind;
12
+ summary?: string;
13
+ }
14
+ export interface TaskRecord {
15
+ runId: string;
16
+ runtime: TaskRuntime;
17
+ label: string;
18
+ command: string;
19
+ workingDir: string;
20
+ pid?: number;
21
+ status: TaskStatus;
22
+ createdAt: number;
23
+ startedAt?: number;
24
+ endedAt?: number;
25
+ lastEventAt?: number;
26
+ exitCode?: number;
27
+ error?: string;
28
+ progressSummary?: string;
29
+ terminalSummary?: string;
30
+ terminalOutcome?: TaskTerminalOutcome;
31
+ }
32
+ export declare function isTerminalTaskStatus(s: TaskStatus): boolean;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Task subsystem types. Mirrors openclaw/openclaw src/tasks/task-registry.types.ts
3
+ * with channel/delivery fields stripped — Franklin is CLI-first single-user.
4
+ */
5
+ const TERMINAL = new Set([
6
+ 'succeeded',
7
+ 'failed',
8
+ 'timed_out',
9
+ 'cancelled',
10
+ 'lost',
11
+ ]);
12
+ export function isTerminalTaskStatus(s) {
13
+ return TERMINAL.has(s);
14
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Detach capability — start a detached background Bash command.
3
+ *
4
+ * Returns immediately with a runId. The command continues even if Franklin
5
+ * exits or the user closes their terminal. Manage running tasks with
6
+ * `franklin task list / tail / wait / cancel`.
7
+ */
8
+ import type { CapabilityHandler } from '../agent/types.js';
9
+ export declare const detachCapability: CapabilityHandler;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Detach capability — start a detached background Bash command.
3
+ *
4
+ * Returns immediately with a runId. The command continues even if Franklin
5
+ * exits or the user closes their terminal. Manage running tasks with
6
+ * `franklin task list / tail / wait / cancel`.
7
+ */
8
+ import { startDetachedTask } from '../tasks/spawn.js';
9
+ async function execute(input, ctx) {
10
+ const { label, command } = input;
11
+ if (typeof label !== 'string' || label.length === 0) {
12
+ return { output: 'Error: label is required (non-empty string)', isError: true };
13
+ }
14
+ if (typeof command !== 'string' || command.length === 0) {
15
+ return { output: 'Error: command is required (non-empty string)', isError: true };
16
+ }
17
+ const runId = startDetachedTask({ label, command, workingDir: ctx.workingDir });
18
+ return {
19
+ output: `Detached task started.\n` +
20
+ `runId: ${runId}\n` +
21
+ `label: ${label}\n` +
22
+ `command: ${command}\n\n` +
23
+ `Inspect with:\n` +
24
+ ` franklin task tail ${runId} --follow\n` +
25
+ ` franklin task wait ${runId}\n` +
26
+ ` franklin task cancel ${runId}\n`,
27
+ };
28
+ }
29
+ export const detachCapability = {
30
+ spec: {
31
+ name: 'Detach',
32
+ description: "Run a Bash command as a detached background job. Returns immediately " +
33
+ "with a runId. The command continues even if Franklin exits or the user " +
34
+ "closes their terminal. Use this for any iteration over more than ~20 " +
35
+ "items, large data fetches, paginated API loops, or anything you'd " +
36
+ "otherwise loop on turn-by-turn (which would burn turns and trip " +
37
+ "timeouts). The agent's job is to design and orchestrate, not to be " +
38
+ "the for-loop. Pair with a script that writes a checkpoint file so " +
39
+ "progress survives restarts. Tail logs with `franklin task tail " +
40
+ "<runId> --follow` and check completion with `franklin task wait " +
41
+ "<runId>`.",
42
+ input_schema: {
43
+ type: 'object',
44
+ properties: {
45
+ label: { type: 'string', description: 'Short human-readable label, e.g. "scrape stargazers"' },
46
+ command: { type: 'string', description: 'Bash command to run. Will be executed via `bash -lc`.' },
47
+ },
48
+ required: ['label', 'command'],
49
+ },
50
+ },
51
+ execute,
52
+ concurrent: true,
53
+ };
@@ -11,6 +11,7 @@ import { grepCapability } from './grep.js';
11
11
  import { webFetchCapability } from './webfetch.js';
12
12
  import { webSearchCapability } from './websearch.js';
13
13
  import { taskCapability } from './task.js';
14
+ import { detachCapability } from './detach.js';
14
15
  /**
15
16
  * Reset module-level tool state that would otherwise leak between sessions
16
17
  * when the same process runs `interactiveSession()` more than once (library
@@ -19,5 +20,5 @@ import { taskCapability } from './task.js';
19
20
  export declare function resetToolSessionState(): void;
20
21
  /** All capabilities available to the Franklin agent (excluding sub-agent, which needs config). */
21
22
  export declare const allCapabilities: CapabilityHandler[];
22
- export { readCapability, writeCapability, editCapability, bashCapability, globCapability, grepCapability, webFetchCapability, webSearchCapability, taskCapability, };
23
+ export { readCapability, writeCapability, editCapability, bashCapability, globCapability, grepCapability, webFetchCapability, webSearchCapability, taskCapability, detachCapability, };
23
24
  export { createSubAgentCapability } from './subagent.js';
@@ -12,6 +12,7 @@ import { grepCapability } from './grep.js';
12
12
  import { webFetchCapability, clearSessionState as clearWebFetchSessionState } from './webfetch.js';
13
13
  import { webSearchCapability } from './websearch.js';
14
14
  import { taskCapability } from './task.js';
15
+ import { detachCapability } from './detach.js';
15
16
  import { createImageGenCapability } from './imagegen.js';
16
17
  import { createVideoGenCapability } from './videogen.js';
17
18
  import { createMusicGenCapability } from './musicgen.js';
@@ -125,6 +126,7 @@ export const allCapabilities = [
125
126
  webFetchCapability,
126
127
  webSearchCapability,
127
128
  taskCapability,
129
+ detachCapability,
128
130
  defaultImageGenCapability,
129
131
  defaultVideoGenCapability,
130
132
  defaultMusicGenCapability,
@@ -143,5 +145,5 @@ export const allCapabilities = [
143
145
  webhookPostCapability,
144
146
  walletCapability,
145
147
  ];
146
- export { readCapability, writeCapability, editCapability, bashCapability, globCapability, grepCapability, webFetchCapability, webSearchCapability, taskCapability, };
148
+ export { readCapability, writeCapability, editCapability, bashCapability, globCapability, grepCapability, webFetchCapability, webSearchCapability, taskCapability, detachCapability, };
147
149
  export { createSubAgentCapability } from './subagent.js';
@@ -23,6 +23,10 @@ export const CORE_TOOL_NAMES = new Set([
23
23
  'Edit',
24
24
  // Shell execution — needed for running tests, builds, scripts.
25
25
  'Bash',
26
+ // Detached background execution — bash-adjacent: spawns a long-running
27
+ // command that survives Franklin exiting. Belongs in core so the agent
28
+ // can offload >20-item iteration without first activating a meta-tool.
29
+ 'Detach',
26
30
  // Search — code exploration is table stakes.
27
31
  'Grep',
28
32
  'Glob',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.9.5",
3
+ "version": "3.10.0",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {