parallelclaw 1.0.0 → 1.2.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/CHANGELOG.md CHANGED
@@ -2,6 +2,61 @@
2
2
 
3
3
  Notable changes to parallelclaw (formerly memex-mvp). Older history lives in the git log.
4
4
 
5
+ ## 1.2.0 — delegate by talking + the laptop as executor
6
+
7
+ Coordination, now from natural language. Two MCP tools so any MCP client (Claude
8
+ Code, Cursor, OpenClaw) can delegate and check on work without touching the CLI:
9
+ - **`parallelclaw_delegate`** `{prompt, to, kind, content}` — hand a task to
10
+ another of your agents. Fired when you say "tell Kimi to …", "delegate this to
11
+ the VPS agent", "have X do this while I'm away". `to:"any"` broadcasts.
12
+ - **`parallelclaw_tasks`** `{filter: mine|inbox|results|all, status, limit}` —
13
+ status of what you delegated, your inbox, or completed answers.
14
+
15
+ SERVER_INSTRUCTIONS gained an "AGENT COORDINATION" recipe so the agent recognizes
16
+ the delegation intent and surfaces results from the SessionStart 📥 block.
17
+
18
+ ### Mac headless executor (Phase 2b) — the laptop runs delegated tasks too
19
+ `lib/executor.js` + `task-executor install|uninstall|status` / `task-run-once`:
20
+ a launchd timer drains the inbox while the Mac is awake, running each claimed
21
+ safe-kind task through `claude -p` and writing the result back — closing any↔any
22
+ auto-coordination both directions. Selective-auto: only SAFE_KINDS (research /
23
+ content / test / general / review / summarize / analysis) auto-run, addressed-to-me
24
+ only unless `--any`; the exec prompt forbids destructive actions and `claude -p`
25
+ runs without `--dangerously-skip-permissions`. Honest limit: only while awake.
26
+ The always-on cron-prompt flow stays the path for Linux/VPS nodes
27
+ (`docs/design/executor-prompt.md`). Hands-off loop live-proven on the Mac↔Kimi mesh.
28
+
29
+ ## 1.1.0 — agent coordination: hardened task ledger + executor + result-routing
30
+
31
+ The first release where the **coordination layer works end-to-end**: one of your
32
+ agents delegates a task, an always-on agent runs it, and the answer comes back to
33
+ where you asked — riding the existing sync mesh, no new transport.
34
+
35
+ ### Added
36
+ - **`task-claim <id>`** — atomically claim a submitted task for execution (exit 0
37
+ claimed / 3 not-claimable / 1 error; `--json`). The always-on executor uses it
38
+ so two pollers never double-run one task. See `docs/design/executor-prompt.md`
39
+ for the ready-to-paste cron-prompt.
40
+ - **`task-results [--ack] [--json] [--all]`** — results of tasks you delegated,
41
+ to deliver to your surface. The SessionStart hook now injects a "📥 N results
42
+ from tasks you delegated" block automatically (Claude Code requester); a
43
+ Telegram requester's cron DMs them and acks after sending.
44
+
45
+ ### Changed / fixed (ledger correctness — required before unattended execution)
46
+ - **Skew-proof status (#1):** current task state is decided by a monotonic
47
+ per-task `seq` (then lifecycle-rank → ts → rowid), not wall-clock ts. Clock
48
+ skew across nodes can no longer regress a `done` back to `working`.
49
+ - **Claim/lock (#3):** optimistic claim resolves concurrent take attempts to one
50
+ deterministic winner once synced.
51
+ - **#2** a `to:'any'` task lands in every node's inbox (first to claim wins).
52
+ **#4** unique per-event `msg_id` (no same-ms silent drop). **#5** lifecycle
53
+ guard (no un-completing a terminal, no regress to submitted). **#6** `done`/
54
+ `failed` must carry a result.
55
+
56
+ Deferred: #7 `listTasks` scan-bound + index (perf), #8 excluding `agent-task`
57
+ rows from default search/overview. NL delegate tool (2c) and the Mac headless
58
+ `claude -p` runner (2b) are the next slices.
59
+
5
60
  ## 1.0.0 — rebrand: memex → ParallelClaw
6
61
 
7
62
  The product is renamed **ParallelClaw** and repositioned from "AI memory" to a
package/ingest.js CHANGED
@@ -156,7 +156,12 @@ if (subcommand && subcommand !== '--help' && subcommand.startsWith('-') === fals
156
156
  // Foreman tracer (v0) — agent-to-agent task ledger (tasks = agent-task messages)
157
157
  'task-delegate': async () => (await import('./lib/tasks.js')).cmdTaskDelegate(),
158
158
  'task-list': async () => (await import('./lib/tasks.js')).cmdTaskList(),
159
+ 'task-claim': async () => (await import('./lib/tasks.js')).cmdTaskClaim(),
159
160
  'task-update': async () => (await import('./lib/tasks.js')).cmdTaskUpdate(),
161
+ 'task-results': async () => (await import('./lib/tasks.js')).cmdTaskResults(),
162
+ // Phase 2b — Mac headless executor (run delegated inbox tasks via `claude -p`)
163
+ 'task-run-once': async () => (await import('./lib/executor.js')).cmdTaskRunOnce(),
164
+ 'task-executor': async () => (await import('./lib/executor.js')).cmdTaskExecutor(),
160
165
  serve: cmdServe, // explicit foreground; same as no-arg
161
166
  // All scan / export modes fall through to module-level logic at EOF.
162
167
  // cmdServe is a no-op marker so the dispatch doesn't error.
package/lib/cli/index.js CHANGED
@@ -799,6 +799,14 @@ async function cmdContext(args) {
799
799
  pendingTg = listPending();
800
800
  } catch (_) { /* ignore */ }
801
801
 
802
+ // Result-routing (Phase 2d): answers to tasks THIS node delegated that are now
803
+ // terminal and not yet surfaced here — "answer where you asked".
804
+ let taskResults = [];
805
+ try {
806
+ const { listTaskResults } = await import('../tasks.js');
807
+ taskResults = listTaskResults({ undeliveredOnly: true });
808
+ } catch (_) { /* ignore */ }
809
+
802
810
  if (opts.json) {
803
811
  console.log(JSON.stringify({
804
812
  pwd, project, freshness_days: freshnessDays,
@@ -806,6 +814,7 @@ async function cmdContext(args) {
806
814
  fuzzy_matches: filteredFuzzy.length,
807
815
  conversations: all,
808
816
  token_budget: tokenBudget,
817
+ task_results: taskResults,
809
818
  telegram_pending: pendingTg.map((e) => ({
810
819
  chat_title: e.chat_title,
811
820
  chat_type: e.chat_type,
@@ -846,7 +855,21 @@ async function cmdContext(args) {
846
855
  lines.push('');
847
856
  }
848
857
 
849
- if (all.length === 0 && pendingTg.length === 0) {
858
+ if (taskResults.length > 0) {
859
+ lines.push(`### 📥 ${taskResults.length} result${taskResults.length === 1 ? '' : 's'} from tasks you delegated`);
860
+ lines.push('');
861
+ for (const t of taskResults.slice(0, 10)) {
862
+ const icon = t.status === 'done' ? '✓' : '✗';
863
+ lines.push(`- ${icon} **${t.id}** → ${t.to} _(${t.status})_ — ${String(t.prompt || '').slice(0, 70)}`);
864
+ lines.push(` - result: ${String(t.result || '').slice(0, 220)}`);
865
+ }
866
+ if (taskResults.length > 10) lines.push(`- _… and ${taskResults.length - 10} more (run \`memex-sync task-results\`)_`);
867
+ lines.push('');
868
+ lines.push(`**INSTRUCTION FOR THE AGENT:** these are answers to tasks you handed to another of your agents. Surface them to the user briefly, then continue.`);
869
+ lines.push('');
870
+ }
871
+
872
+ if (all.length === 0 && pendingTg.length === 0 && taskResults.length === 0) {
850
873
  lines.push(`_No recent activity in memex for this project (last ${freshnessDays} days)._`);
851
874
  lines.push('');
852
875
  lines.push(`Path searched: \`${pwd}\``);
@@ -895,6 +918,11 @@ async function cmdContext(args) {
895
918
  out += '\n\n_[context truncated — exceeds token budget]_\n';
896
919
  }
897
920
 
921
+ // Mark surfaced results delivered so they don't re-show every session. Skip on
922
+ // --no-ack (preview/testing) — json/help paths returned earlier and never ack.
923
+ if (taskResults.length > 0 && !(opts.noAck || opts['no-ack'])) {
924
+ try { const { markResultsDelivered } = await import('../tasks.js'); markResultsDelivered(taskResults.map((t) => t.id)); } catch (_) { /* re-show next time */ }
925
+ }
898
926
  process.stdout.write(out);
899
927
  }
900
928
 
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Mac headless executor (Phase 2b).
3
+ *
4
+ * Lets THIS node (a laptop running Claude Code) be an executor too: while the
5
+ * Mac is awake, a launchd timer drains the ParallelClaw inbox by running each
6
+ * claimed task through `claude -p` (headless Claude Code) and writing the result
7
+ * back. The always-on nodes (Kimi/OpenClaw) use the cron-PROMPT instead
8
+ * (docs/design/executor-prompt.md); this is the code path for the laptop.
9
+ *
10
+ * Honest limit: launchd StartInterval only fires while the Mac is awake — a
11
+ * sleeping laptop simply doesn't run, and the task WAITS in the ledger. There is
12
+ * no cloud Claude Code, so "run it now while my laptop is closed" can't happen.
13
+ *
14
+ * Safety (selective-auto): only tasks whose `kind` is in SAFE_KINDS auto-run,
15
+ * and (by default) only those addressed to me — broadcasts ("any") are left for
16
+ * the always-on nodes unless --any is passed. The exec prompt forbids
17
+ * destructive/irreversible actions; `claude -p` runs WITHOUT
18
+ * --dangerously-skip-permissions, so risky tools are declined rather than
19
+ * silently performed.
20
+ */
21
+
22
+ import { homedir, platform } from 'node:os';
23
+ import { join, resolve } from 'node:path';
24
+ import { writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
25
+ import { execFile, execSync } from 'node:child_process';
26
+
27
+ import { getOrigin } from './config.js';
28
+ import { listTasks, claimTask, updateTask } from './tasks.js';
29
+
30
+ const HOME = homedir();
31
+ const MEMEX_DIR = process.env.MEMEX_DIR || join(HOME, '.memex');
32
+ const DATA = join(MEMEX_DIR, 'data');
33
+
34
+ const EXEC_LABEL = 'com.parallelclaw.memex.executor';
35
+ const EXEC_PLIST = join(HOME, 'Library', 'LaunchAgents', `${EXEC_LABEL}.plist`);
36
+ const EXEC_OUT_LOG = join(DATA, 'executor.out.log');
37
+ const EXEC_ERR_LOG = join(DATA, 'executor.err.log');
38
+
39
+ // Auto-runnable task classes. Read/produce-text work is safe to run unattended;
40
+ // anything that mutates another machine's state is intentionally absent and is
41
+ // left in the inbox for explicit handling (one-tap approval = future v1 UI).
42
+ export const SAFE_KINDS = ['research', 'content', 'test', 'general', 'review', 'summarize', 'analysis'];
43
+
44
+ const DEFAULT_TIMEOUT_MS = 240000; // 4 min — matches the always-on cron budget
45
+
46
+ function buildExecPrompt(env) {
47
+ return [
48
+ 'You are an autonomous ParallelClaw executor running headlessly via `claude -p`.',
49
+ 'A task was delegated to you by another of the user\'s agents. Do it, then print ONLY the',
50
+ 'result/output (no preamble, no "I will…").',
51
+ '',
52
+ 'SAFETY: do NOT perform destructive or irreversible actions — no deploys, no git push/',
53
+ 'force-push, no deleting or overwriting files outside a scratch directory, no spending, no',
54
+ 'outbound messages. If the task genuinely requires any of that, instead print exactly what',
55
+ 'needs human approval and stop.',
56
+ '',
57
+ `TASK (kind: ${env.kind || 'general'}, from: ${env.from || '?'}):`,
58
+ env.prompt || '',
59
+ env.content ? `\nADDITIONAL CONTEXT:\n${env.content}` : '',
60
+ ].join('\n');
61
+ }
62
+
63
+ /** Default runner: headless `claude -p`. Returns stdout; rejects on error/timeout. */
64
+ function claudeRunner(prompt, { timeoutMs = DEFAULT_TIMEOUT_MS, cwd } = {}) {
65
+ return new Promise((res, rej) => {
66
+ const bin = process.env.PARALLELCLAW_CLAUDE_BIN || 'claude';
67
+ execFile(bin, ['-p', prompt],
68
+ { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024, encoding: 'utf8', cwd: cwd || DATA },
69
+ (err, stdout, stderr) => {
70
+ if (err) {
71
+ if (err.killed) return rej(new Error(`claude -p timed out after ${timeoutMs}ms`));
72
+ return rej(new Error(String(stderr || err.message).slice(0, 300)));
73
+ }
74
+ res(String(stdout || '').trim());
75
+ });
76
+ });
77
+ }
78
+
79
+ /**
80
+ * One drain pass over the inbox. opts:
81
+ * db? — shared connection (else opens its own writable handle)
82
+ * runner? — async (prompt, {timeoutMs}) => string (default: claude -p) — injectable for tests
83
+ * allowKinds? — auto-run allowlist (default SAFE_KINDS)
84
+ * includeBroadcast? — also take to:'any' tasks (default false — leave for always-on nodes)
85
+ * timeoutMs?, now?, log?
86
+ * Returns [{ id, status, skipped? }] for what it acted on.
87
+ */
88
+ export async function runInboxOnce({
89
+ db = null, runner = null, allowKinds = SAFE_KINDS, includeBroadcast = false,
90
+ timeoutMs = DEFAULT_TIMEOUT_MS, now = Date.now(), log = () => {},
91
+ } = {}) {
92
+ const me = getOrigin();
93
+ const exec = runner || claudeRunner;
94
+ const acted = [];
95
+ let inbox = listTasks({ inbox: true, db, limit: 50 });
96
+ inbox = inbox.filter((t) => t.to === me || (includeBroadcast && t.to === 'any'));
97
+ for (const t of inbox) {
98
+ if (!allowKinds.includes(t.kind)) {
99
+ log(`skip ${t.id}: kind '${t.kind}' not in auto-allowlist (left for one-tap)`);
100
+ acted.push({ id: t.id, status: 'skipped', reason: 'kind' });
101
+ continue;
102
+ }
103
+ const claimed = claimTask(t.id, { db, now });
104
+ if (!claimed) { log(`skip ${t.id}: not claimable (taken/raced)`); acted.push({ id: t.id, status: 'skipped', reason: 'claim' }); continue; }
105
+ try {
106
+ const out = await exec(buildExecPrompt(claimed), { timeoutMs });
107
+ const result = (out && String(out).trim()) || '(executor produced no output)';
108
+ updateTask(t.id, 'done', { result, db });
109
+ log(`done ${t.id}`);
110
+ acted.push({ id: t.id, status: 'done' });
111
+ } catch (e) {
112
+ updateTask(t.id, 'failed', { result: `executor error: ${e.message}`.slice(0, 500), db });
113
+ log(`failed ${t.id}: ${e.message}`);
114
+ acted.push({ id: t.id, status: 'failed' });
115
+ }
116
+ }
117
+ return acted;
118
+ }
119
+
120
+ // ── launchd (macOS) — run one drain pass every N min while awake ──────────────
121
+
122
+ function resolveClaudeBin() {
123
+ try { return execSync('command -v claude', { encoding: 'utf8' }).trim() || 'claude'; }
124
+ catch (_) { return 'claude'; }
125
+ }
126
+
127
+ export function buildExecutorLaunchAgentPlist({ script, mins, nodePath, claudeBin, includeBroadcast }) {
128
+ const interval = Math.max(60, Math.floor(mins * 60));
129
+ const argv = [nodePath, script, 'task-run-once'];
130
+ if (includeBroadcast) argv.push('--any');
131
+ const argXml = argv.map((a) => ` <string>${a}</string>`).join('\n');
132
+ // launchd's PATH is minimal and excludes /opt/homebrew/bin where `claude`
133
+ // usually lives — bake an explicit PATH + the resolved claude path so the
134
+ // headless run finds it. (Same class of bug as the brew `timeout` PATH trap.)
135
+ return `<?xml version="1.0" encoding="UTF-8"?>
136
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
137
+ <plist version="1.0">
138
+ <dict>
139
+ <key>Label</key>
140
+ <string>${EXEC_LABEL}</string>
141
+ <key>ProgramArguments</key>
142
+ <array>
143
+ ${argXml}
144
+ </array>
145
+ <key>EnvironmentVariables</key>
146
+ <dict>
147
+ <key>HOME</key><string>${HOME}</string>
148
+ <key>MEMEX_DIR</key><string>${MEMEX_DIR}</string>
149
+ <key>PARALLELCLAW_CLAUDE_BIN</key><string>${claudeBin}</string>
150
+ <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
151
+ </dict>
152
+ <key>RunAtLoad</key><true/>
153
+ <key>StartInterval</key><integer>${interval}</integer>
154
+ <key>ProcessType</key><string>Background</string>
155
+ <key>StandardOutPath</key><string>${EXEC_OUT_LOG}</string>
156
+ <key>StandardErrorPath</key><string>${EXEC_ERR_LOG}</string>
157
+ <key>WorkingDirectory</key><string>${resolve(script, '..')}</string>
158
+ </dict>
159
+ </plist>
160
+ `;
161
+ }
162
+
163
+ export function installExecutor({ scriptPath, everyMinutes = 10, nodePath = process.execPath, includeBroadcast = false } = {}) {
164
+ if (platform() !== 'darwin') {
165
+ throw new Error('The headless executor service is macOS-only. On an always-on Linux node, use the cron-prompt in docs/design/executor-prompt.md instead.');
166
+ }
167
+ const script = resolve(scriptPath || process.argv[1]);
168
+ if (!existsSync(script)) throw new Error(`installExecutor: script not found at ${script}`);
169
+ const mins = Math.max(1, Math.floor(Number(everyMinutes) || 10));
170
+ const claudeBin = resolveClaudeBin();
171
+ mkdirSync(DATA, { recursive: true });
172
+ mkdirSync(join(HOME, 'Library', 'LaunchAgents'), { recursive: true });
173
+ const plist = buildExecutorLaunchAgentPlist({ script, mins, nodePath, claudeBin, includeBroadcast });
174
+ try { execSync(`launchctl unload ${JSON.stringify(EXEC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
175
+ writeFileSync(EXEC_PLIST, plist);
176
+ execSync(`launchctl load ${JSON.stringify(EXEC_PLIST)}`, { stdio: 'inherit' });
177
+ return { platform: 'darwin', unitPath: EXEC_PLIST, everyMinutes: mins, claudeBin, includeBroadcast };
178
+ }
179
+
180
+ export function uninstallExecutor() {
181
+ if (platform() !== 'darwin') return { platform: platform(), unitPath: null };
182
+ try { execSync(`launchctl unload ${JSON.stringify(EXEC_PLIST)}`, { stdio: 'ignore' }); } catch (_) {}
183
+ if (existsSync(EXEC_PLIST)) unlinkSync(EXEC_PLIST);
184
+ return { platform: 'darwin', unitPath: EXEC_PLIST };
185
+ }
186
+
187
+ export function executorStatus() {
188
+ if (platform() !== 'darwin') return { installed: false, running: false, manager: 'none' };
189
+ const installed = existsSync(EXEC_PLIST);
190
+ let running = false, detail = '';
191
+ if (installed) {
192
+ try {
193
+ const out = execSync(`launchctl list 2>/dev/null | grep ${EXEC_LABEL} || true`, { encoding: 'utf8' });
194
+ running = out.trim().length > 0; detail = out.trim();
195
+ } catch (_) {}
196
+ }
197
+ return { installed, running, manager: 'launchd', unitPath: EXEC_PLIST, detail };
198
+ }
199
+
200
+ // ── CLI ──────────────────────────────────────────────────────────────────────
201
+
202
+ export async function cmdTaskRunOnce() {
203
+ const argv = process.argv.slice(3);
204
+ const includeBroadcast = argv.includes('--any');
205
+ const acted = await runInboxOnce({ includeBroadcast, log: (m) => console.error(`[executor] ${m}`) });
206
+ const ran = acted.filter((a) => a.status === 'done' || a.status === 'failed');
207
+ if (!ran.length) { console.log('executor: nothing to run.'); process.exit(0); }
208
+ for (const r of ran) console.log(`${r.status === 'done' ? '✓' : '✗'} ${r.id} ${r.status}`);
209
+ process.exit(0);
210
+ }
211
+
212
+ export function cmdTaskExecutor() {
213
+ const sub = process.argv[3];
214
+ const argv = process.argv.slice(4);
215
+ const getOpt = (flag, def) => { const i = argv.indexOf(flag); return i >= 0 && argv[i + 1] ? argv[i + 1] : def; };
216
+ try {
217
+ if (sub === 'install') {
218
+ const r = installExecutor({
219
+ everyMinutes: parseInt(getOpt('--every', '10'), 10) || 10,
220
+ includeBroadcast: argv.includes('--any'),
221
+ });
222
+ console.log(`✓ Mac executor installed (every ${r.everyMinutes}m while awake).`);
223
+ console.log(` claude: ${r.claudeBin} broadcast tasks: ${r.includeBroadcast ? 'yes' : 'no (addressed-only)'}`);
224
+ console.log(` auto-runs kinds: ${SAFE_KINDS.join(', ')} — others wait in the inbox.`);
225
+ console.log(` unit: ${r.unitPath}`);
226
+ } else if (sub === 'uninstall') {
227
+ uninstallExecutor(); console.log('✓ Mac executor uninstalled (tasks/data preserved).');
228
+ } else if (sub === 'status') {
229
+ const s = executorStatus();
230
+ console.log(`executor: installed=${s.installed} running=${s.running} (${s.manager})`);
231
+ if (s.detail) console.log(` ${s.detail}`);
232
+ } else {
233
+ console.error('usage: task-executor <install|uninstall|status> [--every <min>] [--any]');
234
+ process.exit(2);
235
+ }
236
+ } catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
237
+ process.exit(0);
238
+ }
package/lib/tasks.js CHANGED
@@ -24,13 +24,31 @@
24
24
  */
25
25
 
26
26
  import { homedir } from 'node:os';
27
- import { join } from 'node:path';
27
+ import { join, dirname } from 'node:path';
28
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
28
29
  import Database from 'better-sqlite3';
29
30
 
30
31
  import { getOrigin } from './config.js';
31
32
 
32
33
  const SOURCE = 'agent-task';
33
34
  const STATUSES = ['submitted', 'working', 'done', 'failed'];
35
+ const TERMINAL = new Set(['done', 'failed']);
36
+
37
+ // Pick the CURRENT event of a task WITHOUT trusting wall-clock ts — clock skew
38
+ // across nodes regressed state on the live mesh (a 'done' on a fast clock could
39
+ // sort before a later 'working'). Order by the monotonic per-task `seq`, then a
40
+ // lifecycle rank (terminal wins a seq-tie, so a concurrent 'working' can never
41
+ // un-complete a 'done'), then ts, then rowid — a total, deterministic order
42
+ // every node computes identically once synced.
43
+ const STATUS_RANK = { submitted: 0, working: 1, failed: 2, done: 3 };
44
+ function cmpEvent(a, b) {
45
+ const sa = a.seq ?? 0, sb = b.seq ?? 0;
46
+ if (sa !== sb) return sa - sb;
47
+ const ra = STATUS_RANK[a.status] ?? -1, rb = STATUS_RANK[b.status] ?? -1;
48
+ if (ra !== rb) return ra - rb;
49
+ if ((a.ts || 0) !== (b.ts || 0)) return (a.ts || 0) - (b.ts || 0);
50
+ return (a.rowid || 0) - (b.rowid || 0);
51
+ }
34
52
 
35
53
  function dbPath() {
36
54
  const dir = process.env.MEMEX_DIR || join(homedir(), '.memex');
@@ -45,9 +63,12 @@ function openDb() {
45
63
  }
46
64
 
47
65
  /** Append one event row for a task. Returns the row's msg_id. */
48
- function writeEvent(db, { taskId, status, envelope, text, origin, now }) {
66
+ function writeEvent(db, { taskId, status, seq, envelope, text, origin, now }) {
49
67
  const ts = Math.floor(now / 1000);
50
- const msgId = `${taskId}.${status}.${now}`;
68
+ // `seq` makes the id unique per event (fixes the same-status-same-ms silent
69
+ // INSERT-OR-IGNORE drop); `origin` disambiguates concurrent cross-node writes
70
+ // at the same seq. Computed once at write time → stable across sync.
71
+ const msgId = `${taskId}.${seq}.${status}.${origin}.${now}`;
51
72
  db.prepare(
52
73
  `INSERT OR IGNORE INTO messages
53
74
  (source, conversation_id, msg_id, role, sender, text, ts, metadata, origin)
@@ -70,9 +91,9 @@ export function createTask({ prompt, to = 'any', kind = 'general', content = nul
70
91
  const id = `t${now.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
71
92
  try {
72
93
  writeEvent(db, {
73
- taskId: id, status: 'submitted', origin: from, now,
94
+ taskId: id, status: 'submitted', seq: 1, origin: from, now,
74
95
  text: String(prompt),
75
- envelope: { task_id: id, status: 'submitted', from, to, kind,
96
+ envelope: { task_id: id, status: 'submitted', seq: 1, from, to, kind,
76
97
  prompt: String(prompt), content: content || null, result: null },
77
98
  });
78
99
  } finally { if (ownDb) db.close(); }
@@ -91,26 +112,65 @@ export function updateTask(id, status, { result = null, db = null, now = Date.no
91
112
  try {
92
113
  const prev = latestEvent(db, id);
93
114
  if (!prev) throw new Error(`updateTask: no task "${id}"`);
94
- const env = { ...prev.envelope, status, result: result ?? prev.envelope.result ?? null };
115
+ const prevStatus = prev.envelope.status;
116
+ // #5 lifecycle guard: never un-complete a terminal task, never regress to submitted.
117
+ if (TERMINAL.has(prevStatus)) throw new Error(`updateTask: task "${id}" is already ${prevStatus} (terminal) — refusing to set ${status}`);
118
+ if (status === 'submitted') throw new Error(`updateTask: cannot move task "${id}" back to submitted`);
119
+ // #6 a terminal state must say why/what — no silent empty done/failed.
120
+ const carried = result ?? prev.envelope.result ?? null;
121
+ if (TERMINAL.has(status) && !(carried && String(carried).trim())) {
122
+ throw new Error(`updateTask: '${status}' requires a result — pass { result: "<output or reason>" }`);
123
+ }
124
+ const seq = (prev.envelope.seq ?? 0) + 1;
125
+ const env = { ...prev.envelope, status, seq, result: carried };
95
126
  writeEvent(db, {
96
- taskId: id, status, origin: getOrigin(), now,
97
- text: status === 'done' || status === 'failed' ? (result || '') : (env.prompt || ''),
127
+ taskId: id, status, seq, origin: getOrigin(), now,
128
+ text: TERMINAL.has(status) ? (result || carried || '') : (env.prompt || ''),
98
129
  envelope: env,
99
130
  });
100
131
  return env;
101
132
  } finally { if (ownDb) db.close(); }
102
133
  }
103
134
 
104
- /** Latest event for one task id, or null. Returns { envelope, ts, origin }. */
135
+ /**
136
+ * Atomically CLAIM a submitted task for execution — prevents double-execution
137
+ * when several executors/cron-ticks poll the same inbox (#3). Optimistic: write
138
+ * a 'working' event stamped with my origin, then re-read — I hold the claim only
139
+ * if the current event is MY working (cmpEvent picks one deterministic winner
140
+ * across nodes once synced). Returns the working envelope on success, or null if
141
+ * the task wasn't claimable (already taken / terminal) or another node won.
142
+ * opts: { db?, now? }.
143
+ */
144
+ export function claimTask(id, { db = null, now = Date.now() } = {}) {
145
+ const ownDb = !db;
146
+ db = db || openDb();
147
+ try {
148
+ const me = getOrigin();
149
+ const prev = latestEvent(db, id);
150
+ if (!prev) throw new Error(`claimTask: no task "${id}"`);
151
+ if (prev.envelope.status !== 'submitted') return null; // already working/terminal — not claimable
152
+ const seq = (prev.envelope.seq ?? 0) + 1;
153
+ const env = { ...prev.envelope, status: 'working', seq, result: prev.envelope.result ?? null };
154
+ writeEvent(db, { taskId: id, status: 'working', seq, origin: me, now, text: env.prompt || '', envelope: env });
155
+ const after = latestEvent(db, id);
156
+ if (after && after.envelope.status === 'working' && after.origin === me) return after.envelope;
157
+ return null; // another executor's working won the race
158
+ } finally { if (ownDb) db.close(); }
159
+ }
160
+
161
+ /** Current event for one task id, or null. Returns { envelope, ts, origin }. */
105
162
  function latestEvent(db, id) {
106
163
  const rows = db.prepare(
107
- `SELECT metadata, ts, origin FROM messages
108
- WHERE source = ? AND conversation_id = ? ORDER BY ts DESC, id DESC LIMIT 1`
164
+ `SELECT id AS rowid, metadata, ts, origin FROM messages
165
+ WHERE source = ? AND conversation_id = ? ORDER BY ts ASC, id ASC`
109
166
  ).all(SOURCE, `task-${id}`);
110
- if (!rows.length) return null;
111
- let envelope = {};
112
- try { envelope = JSON.parse(rows[0].metadata || '{}'); } catch (_) {}
113
- return { envelope, ts: rows[0].ts, origin: rows[0].origin };
167
+ let best = null;
168
+ for (const r of rows) {
169
+ let env; try { env = JSON.parse(r.metadata || '{}'); } catch (_) { continue; }
170
+ const cand = { envelope: env, ts: r.ts, origin: r.origin, status: env.status, seq: env.seq, rowid: r.rowid };
171
+ if (!best || cmpEvent(cand, best) > 0) best = cand;
172
+ }
173
+ return best ? { envelope: best.envelope, ts: best.ts, origin: best.origin } : null;
114
174
  }
115
175
 
116
176
  /**
@@ -128,17 +188,20 @@ export function listTasks({ forOrigin = null, status = null, mine = false, inbox
128
188
  // All events; reduce to latest per task in JS (low volume; robust to
129
189
  // cross-node id ordering — we key on logical ts, not local rowid).
130
190
  const rows = db.prepare(
131
- `SELECT conversation_id, metadata, ts FROM messages
191
+ `SELECT id AS rowid, conversation_id, metadata, ts FROM messages
132
192
  WHERE source = ? ORDER BY ts ASC, id ASC`
133
193
  ).all(SOURCE);
134
194
  const latest = new Map();
135
195
  for (const r of rows) {
136
196
  let env; try { env = JSON.parse(r.metadata || '{}'); } catch (_) { continue; }
137
197
  if (!env.task_id) continue;
138
- latest.set(env.task_id, { ...env, ts: r.ts }); // later rows overwrite → latest wins
198
+ const cand = { ...env, ts: r.ts, rowid: r.rowid };
199
+ const cur = latest.get(env.task_id);
200
+ if (!cur || cmpEvent(cand, cur) > 0) latest.set(env.task_id, cand); // skew-proof: seq, not rowid
139
201
  }
140
202
  let out = [...latest.values()];
141
- if (inbox) out = out.filter((t) => (t.to === me) && t.status === 'submitted');
203
+ // #2 'any' is a broadcast: it lands in EVERY node's inbox (claim resolves the winner).
204
+ if (inbox) out = out.filter((t) => (t.to === me || t.to === 'any') && t.status === 'submitted');
142
205
  if (mine) out = out.filter((t) => t.from === me);
143
206
  if (forOrigin) out = out.filter((t) => t.to === forOrigin || t.from === forOrigin);
144
207
  if (status) out = out.filter((t) => t.status === status);
@@ -150,6 +213,48 @@ export function listTasks({ forOrigin = null, status = null, mine = false, inbox
150
213
  } finally { if (ownDb) db.close(); }
151
214
  }
152
215
 
216
+ // ── Result-routing (Phase 2d) ────────────────────────────────────────────────
217
+ // "Answer where you asked": a requester surfaces the results of tasks IT
218
+ // delegated once they're terminal. "Delivered" is per-node UX state (which
219
+ // results this surface already showed) — kept LOCAL in a small JSON file, NOT
220
+ // in the synced ledger (delivery is surface-specific, not task state).
221
+
222
+ function seenPath() {
223
+ const dir = process.env.MEMEX_DIR || join(homedir(), '.memex');
224
+ return join(dir, 'task-results-seen.json');
225
+ }
226
+ function readDelivered() {
227
+ try { return new Set(JSON.parse(readFileSync(seenPath(), 'utf-8')).delivered || []); }
228
+ catch (_) { return new Set(); }
229
+ }
230
+ /** Mark task ids as delivered to this node's surface (idempotent). */
231
+ export function markResultsDelivered(ids = []) {
232
+ if (!ids || !ids.length) return;
233
+ const set = readDelivered();
234
+ for (const id of ids) set.add(id);
235
+ try {
236
+ const p = seenPath();
237
+ mkdirSync(dirname(p), { recursive: true });
238
+ writeFileSync(p, JSON.stringify({ delivered: [...set] }));
239
+ } catch (_) { /* best-effort; re-show next time rather than crash */ }
240
+ }
241
+
242
+ /**
243
+ * Results to route back to a requester surface: tasks THIS origin delegated
244
+ * (from === forOrigin) that are now terminal (done/failed), newest-first.
245
+ * undeliveredOnly drops ids already shown on this node. opts: { forOrigin, undeliveredOnly, db? }.
246
+ */
247
+ export function listTaskResults({ forOrigin = null, undeliveredOnly = false, db = null } = {}) {
248
+ const ownDb = !db;
249
+ db = db || openDb();
250
+ try {
251
+ const me = forOrigin || getOrigin();
252
+ let out = listTasks({ db, limit: 1000 }).filter((t) => t.from === me && TERMINAL.has(t.status));
253
+ if (undeliveredOnly) { const seen = readDelivered(); out = out.filter((t) => !seen.has(t.id)); }
254
+ return out;
255
+ } finally { if (ownDb) db.close(); }
256
+ }
257
+
153
258
  // ── CLI ──────────────────────────────────────────────────────────────────────
154
259
 
155
260
  function parseFlags(argv) {
@@ -194,6 +299,32 @@ export function cmdTaskUpdate() {
194
299
  process.exit(0);
195
300
  }
196
301
 
302
+ export function cmdTaskClaim() {
303
+ const args = parseFlags(process.argv.slice(3));
304
+ const id = (args._ || [])[0];
305
+ if (!id) {
306
+ console.error('usage: task-claim <id> [--json] (atomically take a submitted task for execution)');
307
+ process.exit(2);
308
+ }
309
+ let env;
310
+ try { env = claimTask(id); }
311
+ catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
312
+ if (!env) {
313
+ // exit 3 = "not claimable" (already taken / terminal / lost the race) — the
314
+ // executor loop should treat this as "skip", NOT an error.
315
+ if ('--json' in args) console.log(JSON.stringify({ claimed: false, id }));
316
+ else console.log(`· task ${id} not claimable (already taken or terminal) — skip`);
317
+ process.exit(3);
318
+ }
319
+ if ('--json' in args) { console.log(JSON.stringify({ claimed: true, id, ...env })); process.exit(0); }
320
+ console.log(`✓ claimed ${id} → working (by ${getOrigin()})`);
321
+ console.log(` from ${env.from} · kind: ${env.kind || 'general'}`);
322
+ console.log(` prompt: ${env.prompt || ''}`);
323
+ if (env.content) console.log(` content: ${env.content}`);
324
+ console.log(` on finish: task-update ${id} done --result "<output>" (or: failed --result "<why>")`);
325
+ process.exit(0);
326
+ }
327
+
197
328
  export function cmdTaskList() {
198
329
  const args = parseFlags(process.argv.slice(3));
199
330
  const tasks = listTasks({
@@ -213,3 +344,28 @@ export function cmdTaskList() {
213
344
  }
214
345
  process.exit(0);
215
346
  }
347
+
348
+ // task-results [--ack] [--json] [--all]
349
+ // Results of tasks I delegated, to deliver to my surface (Phase 2d). The
350
+ // requester surface calls this: Claude Code via the SessionStart hook (auto),
351
+ // an OpenClaw/Telegram requester via a cron-prompt that DMs them. --ack marks
352
+ // them delivered so they don't re-show; --all ignores the delivered set.
353
+ export function cmdTaskResults() {
354
+ const args = parseFlags(process.argv.slice(3));
355
+ const results = listTaskResults({ undeliveredOnly: !('--all' in args) });
356
+ if ('--json' in args) {
357
+ console.log(JSON.stringify(results));
358
+ if ('--ack' in args) markResultsDelivered(results.map((r) => r.id));
359
+ process.exit(0);
360
+ }
361
+ if (!results.length) { console.log('No new task results.'); process.exit(0); }
362
+ console.log(`📥 ${results.length} result(s) from tasks you delegated:`);
363
+ for (const t of results) {
364
+ const icon = t.status === 'done' ? '✓' : '✗';
365
+ console.log(`${icon} ${t.id} → ${t.to} [${t.status}]`);
366
+ console.log(` task: ${String(t.prompt || '').slice(0, 80)}`);
367
+ console.log(` result: ${String(t.result || '').slice(0, 200)}`);
368
+ }
369
+ if ('--ack' in args) { markResultsDelivered(results.map((r) => r.id)); console.log(`(${results.length} marked delivered)`); }
370
+ process.exit(0);
371
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parallelclaw",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Local-first personal AI ops layer. Shared verbatim memory across your agents (Claude Code, Cursor, OpenClaw, Hermes, Obsidian, Telegram) in one SQLite + FTS5 corpus — plus a coordination layer where any of your agents can delegate tasks to any other. Searchable from any MCP-compatible client.",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -31,7 +31,7 @@
31
31
  "sync": "node ingest.js",
32
32
  "ingest": "node ingest.js",
33
33
  "bot": "node bot/index.js",
34
- "test": "node test/parser.test.js && node test/bot-inbox.test.js && node test/search-sort.test.js && node test/get-conversation-paging.test.js && node test/search-filters.test.js && node test/origin.test.js && node test/tasks.test.js && node test/store-document.test.js && node test/cli.test.js && node test/hook.test.js && node test/telegram-html.test.js && node test/telegram-decisions.test.js && node test/telegram-pending.test.js && node test/telegram-notify.test.js && node test/notify-click-action.test.js && node test/inbox-watcher.test.js && node test/e2e-inbox.test.js && node test/ingest-file.test.js && node test/openclaw-channel.test.js && node test/openclaw-channel-e2e.test.js && node test/wire-openclaw.test.js && node test/sync/server-bootstrap.test.js && node test/sync/push-pull-roundtrip.test.js && node test/sync/cli-end-to-end.test.js && node test/sync/adaptive-batch.test.js && node test/sync/service.test.js && node test/sync/skip-accounting.test.js && node test/sync/pair.test.js && node test/sync/join-token.test.js && node test/sync/tunnel-service.test.js && node test/sync/mcp-invite.test.js && node test/sync/skip-accounting.test.js",
34
+ "test": "node test/parser.test.js && node test/bot-inbox.test.js && node test/search-sort.test.js && node test/get-conversation-paging.test.js && node test/search-filters.test.js && node test/origin.test.js && node test/tasks.test.js && node test/executor.test.js && node test/store-document.test.js && node test/cli.test.js && node test/hook.test.js && node test/telegram-html.test.js && node test/telegram-decisions.test.js && node test/telegram-pending.test.js && node test/telegram-notify.test.js && node test/notify-click-action.test.js && node test/inbox-watcher.test.js && node test/e2e-inbox.test.js && node test/ingest-file.test.js && node test/openclaw-channel.test.js && node test/openclaw-channel-e2e.test.js && node test/wire-openclaw.test.js && node test/sync/server-bootstrap.test.js && node test/sync/push-pull-roundtrip.test.js && node test/sync/cli-end-to-end.test.js && node test/sync/adaptive-batch.test.js && node test/sync/service.test.js && node test/sync/skip-accounting.test.js && node test/sync/pair.test.js && node test/sync/join-token.test.js && node test/sync/tunnel-service.test.js && node test/sync/mcp-invite.test.js && node test/sync/skip-accounting.test.js",
35
35
  "prepublishOnly": "npm test"
36
36
  },
37
37
  "engines": {
package/server.js CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  KNOWN_SOURCES,
45
45
  CONFIG_PATH,
46
46
  } from './lib/config.js';
47
+ import { createTask, listTasks, listTaskResults } from './lib/tasks.js';
47
48
  import {
48
49
  canonicalize as canonicalizeUrl,
49
50
  extractDomain,
@@ -1329,6 +1330,18 @@ This merges main + subagent messages in chronological order with a
1329
1330
  titles. Compare short_ids and date ranges before concluding duplicate.
1330
1331
  - Re-imports are idempotent (UNIQUE on msg_id, recount-on-upsert).
1331
1332
 
1333
+ ══ AGENT COORDINATION (delegate tasks across your agents) ══
1334
+
1335
+ ParallelClaw isn't only memory — your agents can hand tasks to each other over
1336
+ the same synced ledger. When the user says "tell Kimi to …", "ask the VPS agent
1337
+ to …", "delegate this to <agent>", or "have <agent> do X while I'm away":
1338
+ → call parallelclaw_delegate({ prompt, to: "<agent>" }) (to:"any" = broadcast)
1339
+ The target agent claims it (atomic — never double-run) and runs it when
1340
+ available; the answer returns to you. To check delegated work or surface answers,
1341
+ call parallelclaw_tasks (filter: mine / inbox / results). Newly-finished results
1342
+ also appear automatically in this session's "📥 results from tasks you delegated"
1343
+ block. Don't shell out to raw SQL or the task-* CLI from here — use these tools.
1344
+
1332
1345
  ══ CONVENTIONS ══
1333
1346
 
1334
1347
  - BE PROACTIVE. If past context would sharpen your answer, pull it
@@ -1985,6 +1998,43 @@ const TOOLS = [
1985
1998
  },
1986
1999
  },
1987
2000
  },
2001
+ {
2002
+ name: 'parallelclaw_delegate',
2003
+ description:
2004
+ 'Hand a task to ANOTHER of the user\'s agents (cross-tool, cross-machine) via the shared ParallelClaw ledger. ' +
2005
+ 'CALL THIS when the user says "tell Kimi to …", "ask the VPS agent to …", "delegate this to <agent>", ' +
2006
+ '"have <agent> do X while I\'m away", or when an always-on agent should run bulk/long work. The task is ' +
2007
+ 'written to the synced ledger; the target agent claims and runs it when available, and the result returns ' +
2008
+ 'to you (your next session\'s 📥 block, or via parallelclaw_tasks filter:"results"). Set `to` to the target ' +
2009
+ 'node\'s origin label (e.g. "kimi", "vps1"); omit or "any" to broadcast to whichever always-on agent grabs ' +
2010
+ 'it first. Destructive cross-machine actions are NOT auto-run — state that in the prompt.',
2011
+ inputSchema: {
2012
+ type: 'object',
2013
+ properties: {
2014
+ prompt: { type: 'string', description: 'What the other agent should do — self-contained; it has no access to this conversation.' },
2015
+ to: { type: 'string', default: 'any', description: 'Target agent origin label (e.g. "kimi", "vps1"), or "any" to broadcast.' },
2016
+ kind: { type: 'string', default: 'general', description: 'Task class hint: research / code / content / test / …' },
2017
+ content: { type: 'string', description: 'Optional extra context/material to carry with the task.' },
2018
+ },
2019
+ required: ['prompt'],
2020
+ },
2021
+ },
2022
+ {
2023
+ name: 'parallelclaw_tasks',
2024
+ description:
2025
+ 'Check the ParallelClaw task ledger. filter="mine" (default): tasks you delegated + their status/result; ' +
2026
+ '"inbox": tasks waiting for THIS node to run; "results": completed answers to your delegations; "all": ' +
2027
+ 'everything. Use after parallelclaw_delegate to see whether the other agent finished, or to surface results.',
2028
+ inputSchema: {
2029
+ type: 'object',
2030
+ properties: {
2031
+ filter: { type: 'string', enum: ['mine', 'inbox', 'results', 'all'], default: 'mine' },
2032
+ status: { type: 'string', enum: ['submitted', 'working', 'done', 'failed'], description: 'Optional status filter.' },
2033
+ limit: { type: 'integer', default: 20, minimum: 1, maximum: 100 },
2034
+ format: { type: 'string', enum: ['markdown', 'json'], default: 'markdown' },
2035
+ },
2036
+ },
2037
+ },
1988
2038
  ];
1989
2039
 
1990
2040
  // v0.11.11 experimental sync — expose the one-paste pairing tool ONLY when
@@ -3575,6 +3625,41 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
3575
3625
  });
3576
3626
  }
3577
3627
 
3628
+ if (name === 'parallelclaw_delegate') {
3629
+ const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '';
3630
+ if (!prompt) return textResult('parallelclaw_delegate: `prompt` is required (what should the other agent do?).');
3631
+ const to = (typeof args.to === 'string' && args.to.trim()) ? args.to.trim().toLowerCase() : 'any';
3632
+ const kind = (typeof args.kind === 'string' && args.kind.trim()) ? args.kind.trim() : 'general';
3633
+ const content = typeof args.content === 'string' ? args.content : null;
3634
+ const { id } = createTask({ prompt, to, kind, content });
3635
+ return textResult(
3636
+ `✓ Delegated task \`${id}\` → **${to}** (from ${getOrigin()}).\n` +
3637
+ `Runs when ${to === 'any' ? 'an always-on agent' : to} is available; the result returns to you ` +
3638
+ `(next session's 📥 block, or parallelclaw_tasks filter:"results").`
3639
+ );
3640
+ }
3641
+
3642
+ if (name === 'parallelclaw_tasks') {
3643
+ const filter = ['mine', 'inbox', 'results', 'all'].includes(args.filter) ? args.filter : 'mine';
3644
+ const limit = Math.min(100, Math.max(1, args.limit || 20));
3645
+ const statusFilter = ['submitted', 'working', 'done', 'failed'].includes(args.status) ? args.status : null;
3646
+ let rows;
3647
+ if (filter === 'results') rows = listTaskResults({});
3648
+ else if (filter === 'inbox') rows = listTasks({ inbox: true, limit });
3649
+ else if (filter === 'mine') rows = listTasks({ mine: true, status: statusFilter, limit });
3650
+ else rows = listTasks({ status: statusFilter, limit });
3651
+ if (pickFormat(args) === 'json') return jsonResult({ filter, count: rows.length, tasks: rows });
3652
+ if (!rows.length) return textResult(`No tasks (filter: ${filter}).`);
3653
+ const icon = { submitted: '○', working: '◐', done: '✓', failed: '✗' };
3654
+ const lines = [`ParallelClaw tasks (${filter}):`, ''];
3655
+ for (const t of rows) {
3656
+ lines.push(`${icon[t.status] || '?'} \`${t.id}\` ${t.from} → ${t.to} [${t.status}]`);
3657
+ lines.push(` ${String(t.prompt || '').slice(0, 90)}`);
3658
+ if (t.result) lines.push(` └ result: ${String(t.result).slice(0, 200)}`);
3659
+ }
3660
+ return textResult(lines.join('\n'));
3661
+ }
3662
+
3578
3663
  return textResult(`Unknown tool: ${name}`);
3579
3664
  } catch (err) {
3580
3665
  log('tool error:', name, err.message);