@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.
@@ -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.6",
3
+ "version": "3.10.1",
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": {