@blockrun/franklin 3.9.6 → 3.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -174,7 +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)**: Do NOT loop in the agent (one tool call per item burns turns and trips timeouts on the 21st item). Instead: Write a script (Node/Bash/Python), have it iterate with a checkpoint file (\`./.franklin/<task>.checkpoint.json\` storing cursor + processedCount), then Bash it once. The agent re-engages only on errors or completion. Pattern fits paginated APIs, batch enrichment, large CSV emit, anything where the loop body is deterministic. The agent's job is to design and orchestrate, not to be the for-loop.
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.
178
178
 
179
179
  # Grounding Before Answering
180
180
  Your training data is frozen in the past. Live-world questions MUST be answered from tool results, not memory.
@@ -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];