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 +55 -0
- package/ingest.js +5 -0
- package/lib/cli/index.js +29 -1
- package/lib/executor.js +238 -0
- package/lib/tasks.js +174 -18
- package/package.json +2 -2
- package/server.js +85 -0
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 (
|
|
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
|
|
package/lib/executor.js
ADDED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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);
|