castle-web-cli 0.4.26 → 0.4.28
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/dist/agent-prompts.js +8 -1
- package/dist/agent.js +188 -75
- package/dist/serve.js +3 -0
- package/package.json +1 -1
package/dist/agent-prompts.js
CHANGED
|
@@ -13,7 +13,7 @@ What a deck is: a normal web project served by vite -- index.html plus plain JS/
|
|
|
13
13
|
Hard rules:
|
|
14
14
|
- You NEVER edit files or run state-changing commands. All building and fixing happens through background task agents -- always hand the longer work to them.
|
|
15
15
|
- You are the fast lane: get to your final reply as quickly as possible. When the user reports something broken, do NOT dig into the code to diagnose it first -- spawn a task whose job is to investigate AND fix it. Only read deck files when your reply itself needs them (answering a question about the deck, grounding a claim -- never make things up); never read as pre-work before spawning a task.
|
|
16
|
-
-
|
|
16
|
+
- Break the user's request into separately testable, parallelizable chunks -- but chunk by what can actually be TESTED alone, never by entity. Mechanics that only work through their interaction are ONE task: paddle + ball + bricks + their collisions is one breakout core, not three tasks shipping untestable pieces. Get the best initial testable chunk -- the smallest thing the user can genuinely play -- then parallelize the truly independent layers on top (separate features, art, levels, polish).
|
|
17
17
|
- Write task prompts for speed: ask each task for the quickest viable, testable change that still delivers a meaningful step up (one or a few features, pieces of art, etc.). Only tell a task to take its time and dive deep when the user specifically asked for that.
|
|
18
18
|
- To spawn a background task, include a fenced block in your reply:
|
|
19
19
|
|
|
@@ -34,6 +34,13 @@ comma-separated titles or ids of the finished tasks
|
|
|
34
34
|
\`\`\`
|
|
35
35
|
|
|
36
36
|
- NEVER check a task off on your own judgment -- only a clear user statement that it works (or an explicit ask to clear it) counts. When in doubt, leave the row on the board.
|
|
37
|
+
- To STOP tasks (running or waiting) when the user asks or their work is clearly no longer wanted, include:
|
|
38
|
+
|
|
39
|
+
\`\`\`castle-stop
|
|
40
|
+
comma-separated titles or ids of the tasks to stop
|
|
41
|
+
\`\`\`
|
|
42
|
+
|
|
43
|
+
Stopped tasks show as interrupted on the board. If a new task replaces the stopped work, prefer \`supersedes:\` on the new task instead.
|
|
37
44
|
- Tasks are one-and-done -- when the user gives feedback on a finished task, spawn a new fix task rather than reopening the old one.
|
|
38
45
|
- Task agents are capable coding agents working in this same deck directory, but they know nothing about this conversation beyond your prompt.
|
|
39
46
|
|
package/dist/agent.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// Backend CLI: cursor-agent in headless print mode (stream-json). The router
|
|
15
15
|
// runs with --mode ask (read-only at the CLI level); task agents run with
|
|
16
16
|
// --force. Claude support can slot in later behind runAgentCli.
|
|
17
|
-
import { spawn } from 'child_process';
|
|
17
|
+
import { execFileSync, spawn } from 'child_process';
|
|
18
18
|
import * as fs from 'fs';
|
|
19
19
|
import * as path from 'path';
|
|
20
20
|
import { nanoid } from 'nanoid';
|
|
@@ -118,15 +118,19 @@ function visibleLength(raw) {
|
|
|
118
118
|
function extractDirectives(full) {
|
|
119
119
|
const directives = [];
|
|
120
120
|
const checkoffs = [];
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
121
|
+
const stops = [];
|
|
122
|
+
const listFence = (source, name, into) => {
|
|
123
|
+
const re = new RegExp('```' + name + '[ \\t]*\\r?\\n([\\s\\S]*?)```', 'g');
|
|
124
|
+
return source.replace(re, (_match, body) => {
|
|
125
|
+
for (const token of String(body).split(/[,\n]/)) {
|
|
126
|
+
const trimmed = token.trim();
|
|
127
|
+
if (trimmed)
|
|
128
|
+
into.push(trimmed);
|
|
129
|
+
}
|
|
130
|
+
return '';
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
const withoutDone = listFence(listFence(full, 'castle-done', checkoffs), 'castle-stop', stops);
|
|
130
134
|
const fenceRe = /```castle-task[ \t]*\r?\n([\s\S]*?)```/g;
|
|
131
135
|
const cleaned = withoutDone.replace(fenceRe, (_match, body) => {
|
|
132
136
|
const lines = String(body).replace(/\r/g, '').split('\n');
|
|
@@ -148,7 +152,7 @@ function extractDirectives(full) {
|
|
|
148
152
|
}
|
|
149
153
|
return '';
|
|
150
154
|
});
|
|
151
|
-
return { cleaned: cleaned.replace(/\n{3,}/g, '\n\n').trim(), directives, checkoffs };
|
|
155
|
+
return { cleaned: cleaned.replace(/\n{3,}/g, '\n\n').trim(), directives, checkoffs, stops };
|
|
152
156
|
}
|
|
153
157
|
// Claude names tools directly (Read, Edit, Bash, ...).
|
|
154
158
|
function claudeToolActivityLabel(name) {
|
|
@@ -413,9 +417,68 @@ function depsSummaryFor(tasks, task) {
|
|
|
413
417
|
.map((dep) => `- "${dep.title}" finished ${dep.status}${dep.notes.trim() ? `; notes: ${dep.notes.trim()}` : ''}`);
|
|
414
418
|
return lines.join('\n') || undefined;
|
|
415
419
|
}
|
|
420
|
+
async function runTaskAgentIn(ctx, task) {
|
|
421
|
+
const dir = path.join(ctx.tasksDir, task.id);
|
|
422
|
+
const relDir = path.relative(ctx.deckDir, dir);
|
|
423
|
+
const taskPrompt = buildTaskPrompt({
|
|
424
|
+
deckLabel: ctx.deckLabel,
|
|
425
|
+
taskId: task.id,
|
|
426
|
+
title: task.title,
|
|
427
|
+
prompt: task.prompt,
|
|
428
|
+
progressPath: path.join(relDir, 'progress'),
|
|
429
|
+
notesPath: path.join(relDir, 'notes.md'),
|
|
430
|
+
depsSummary: ctx.depsSummary,
|
|
431
|
+
});
|
|
432
|
+
// Claude task runs get /goal (claude code's built-in commit-to-completion
|
|
433
|
+
// command) plus the system-prompt autonomy reminder.
|
|
434
|
+
const invocation = buildAgentInvocation(ctx.backend, 'task', ctx.backend === 'claude' ? `/goal ${taskPrompt}` : taskPrompt, ctx.claudeModel);
|
|
435
|
+
let result = { ok: false, finalText: '', error: 'not run' };
|
|
436
|
+
let lineBuf = '';
|
|
437
|
+
const flushFeedLines = (delta) => {
|
|
438
|
+
lineBuf += delta;
|
|
439
|
+
let nl = lineBuf.indexOf('\n');
|
|
440
|
+
while (nl >= 0) {
|
|
441
|
+
const line = lineBuf.slice(0, nl).trim();
|
|
442
|
+
lineBuf = lineBuf.slice(nl + 1);
|
|
443
|
+
if (line)
|
|
444
|
+
ctx.onFeed(line);
|
|
445
|
+
nl = lineBuf.indexOf('\n');
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
for (let attempt = 1; attempt <= MAX_TASK_ATTEMPTS; attempt++) {
|
|
449
|
+
result = await runAgentCli({
|
|
450
|
+
cwd: ctx.deckDir,
|
|
451
|
+
command: invocation.command,
|
|
452
|
+
args: invocation.args,
|
|
453
|
+
parser: ctx.backend,
|
|
454
|
+
timeoutMs: TASK_TIMEOUT_MS,
|
|
455
|
+
logPath: path.join(dir, 'log.jsonl'),
|
|
456
|
+
children: ctx.children,
|
|
457
|
+
onSpawn: (pid) => {
|
|
458
|
+
task.pid = pid;
|
|
459
|
+
},
|
|
460
|
+
onDelta: (delta) => flushFeedLines(delta),
|
|
461
|
+
onActivity: (activity) => {
|
|
462
|
+
if (activity)
|
|
463
|
+
ctx.onFeed(`[${activity}]`);
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
if (ctx.stopRequested.has(task.id))
|
|
467
|
+
return result;
|
|
468
|
+
if (!result.crashed)
|
|
469
|
+
return result;
|
|
470
|
+
if (attempt < MAX_TASK_ATTEMPTS)
|
|
471
|
+
ctx.onRetry(attempt + 1);
|
|
472
|
+
}
|
|
473
|
+
result.error = `agent process kept dying (${MAX_TASK_ATTEMPTS} attempts): ${result.error ?? ''}`;
|
|
474
|
+
return result;
|
|
475
|
+
}
|
|
416
476
|
function createTaskStore(opts) {
|
|
417
477
|
const { deckDir, deckLabel, tasksDir, children } = opts;
|
|
418
478
|
const tasks = loadTasks(tasksDir);
|
|
479
|
+
// Tasks the router asked to stop: their killed process must not read as a
|
|
480
|
+
// crash (no retry) and they finalize as interrupted, not failed.
|
|
481
|
+
const stopRequested = new Set();
|
|
419
482
|
function sorted() {
|
|
420
483
|
return [...tasks.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
421
484
|
}
|
|
@@ -435,62 +498,6 @@ function createTaskStore(opts) {
|
|
|
435
498
|
return;
|
|
436
499
|
start(task);
|
|
437
500
|
}
|
|
438
|
-
// Run the task agent, re-running if the process dies mid-task (crash, not
|
|
439
|
-
// a normal finish). After MAX_TASK_ATTEMPTS dead processes the task fails.
|
|
440
|
-
async function runTaskAgent(task, dir) {
|
|
441
|
-
const relDir = path.relative(deckDir, dir);
|
|
442
|
-
const backend = opts.backend();
|
|
443
|
-
const taskPrompt = buildTaskPrompt({
|
|
444
|
-
deckLabel,
|
|
445
|
-
taskId: task.id,
|
|
446
|
-
title: task.title,
|
|
447
|
-
prompt: task.prompt,
|
|
448
|
-
progressPath: path.join(relDir, 'progress'),
|
|
449
|
-
notesPath: path.join(relDir, 'notes.md'),
|
|
450
|
-
depsSummary: depsSummaryFor(tasks, task),
|
|
451
|
-
});
|
|
452
|
-
// Claude task runs get /goal (claude code's built-in commit-to-completion
|
|
453
|
-
// command) plus the system-prompt autonomy reminder.
|
|
454
|
-
const invocation = buildAgentInvocation(backend, 'task', backend === 'claude' ? `/goal ${taskPrompt}` : taskPrompt, opts.claudeModel());
|
|
455
|
-
let result = { ok: false, finalText: '', error: 'not run' };
|
|
456
|
-
let lineBuf = '';
|
|
457
|
-
const flushFeedLines = (delta) => {
|
|
458
|
-
lineBuf += delta;
|
|
459
|
-
let nl = lineBuf.indexOf('\n');
|
|
460
|
-
while (nl >= 0) {
|
|
461
|
-
const line = lineBuf.slice(0, nl).trim();
|
|
462
|
-
lineBuf = lineBuf.slice(nl + 1);
|
|
463
|
-
if (line)
|
|
464
|
-
opts.onFeed(task, line);
|
|
465
|
-
nl = lineBuf.indexOf('\n');
|
|
466
|
-
}
|
|
467
|
-
};
|
|
468
|
-
for (let attempt = 1; attempt <= MAX_TASK_ATTEMPTS; attempt++) {
|
|
469
|
-
result = await runAgentCli({
|
|
470
|
-
cwd: deckDir,
|
|
471
|
-
command: invocation.command,
|
|
472
|
-
args: invocation.args,
|
|
473
|
-
parser: backend,
|
|
474
|
-
timeoutMs: TASK_TIMEOUT_MS,
|
|
475
|
-
logPath: path.join(dir, 'log.jsonl'),
|
|
476
|
-
children,
|
|
477
|
-
onSpawn: (pid) => {
|
|
478
|
-
task.pid = pid;
|
|
479
|
-
},
|
|
480
|
-
onDelta: (delta) => flushFeedLines(delta),
|
|
481
|
-
onActivity: (activity) => {
|
|
482
|
-
if (activity)
|
|
483
|
-
opts.onFeed(task, `[${activity}]`);
|
|
484
|
-
},
|
|
485
|
-
});
|
|
486
|
-
if (!result.crashed)
|
|
487
|
-
return result;
|
|
488
|
-
if (attempt < MAX_TASK_ATTEMPTS)
|
|
489
|
-
opts.onRetry(task, attempt + 1);
|
|
490
|
-
}
|
|
491
|
-
result.error = `agent process kept dying (${MAX_TASK_ATTEMPTS} attempts): ${result.error ?? ''}`;
|
|
492
|
-
return result;
|
|
493
|
-
}
|
|
494
501
|
function start(task) {
|
|
495
502
|
const dir = path.join(tasksDir, task.id);
|
|
496
503
|
fs.writeFileSync(path.join(dir, 'progress'), '0\n');
|
|
@@ -500,15 +507,30 @@ function createTaskStore(opts) {
|
|
|
500
507
|
task.startedAt = nowIso();
|
|
501
508
|
touch(task);
|
|
502
509
|
opts.onStarted(task);
|
|
503
|
-
|
|
510
|
+
const runCtx = {
|
|
511
|
+
deckDir,
|
|
512
|
+
deckLabel,
|
|
513
|
+
tasksDir,
|
|
514
|
+
children,
|
|
515
|
+
backend: opts.backend(),
|
|
516
|
+
claudeModel: opts.claudeModel(),
|
|
517
|
+
stopRequested,
|
|
518
|
+
depsSummary: depsSummaryFor(tasks, task),
|
|
519
|
+
onFeed: (entry) => opts.onFeed(task, entry),
|
|
520
|
+
onRetry: (attempt) => opts.onRetry(task, attempt),
|
|
521
|
+
};
|
|
522
|
+
void runTaskAgentIn(runCtx, task).then((result) => {
|
|
504
523
|
refreshTaskFiles(tasksDir, task);
|
|
505
|
-
|
|
506
|
-
|
|
524
|
+
const wasStopped = stopRequested.delete(task.id);
|
|
525
|
+
task.status = wasStopped ? 'interrupted' : result.ok ? 'done' : 'failed';
|
|
526
|
+
if (result.ok && !wasStopped)
|
|
507
527
|
task.progress = 100;
|
|
508
528
|
task.finishedAt = nowIso();
|
|
509
|
-
task.resultSummary =
|
|
510
|
-
?
|
|
511
|
-
:
|
|
529
|
+
task.resultSummary = wasStopped
|
|
530
|
+
? 'stopped by the router'
|
|
531
|
+
: result.ok
|
|
532
|
+
? result.finalText.slice(-RESULT_SUMMARY_CHARS)
|
|
533
|
+
: `${result.error ?? 'failed'}\n${result.finalText.slice(-RESULT_SUMMARY_CHARS)}`;
|
|
512
534
|
touch(task);
|
|
513
535
|
opts.onFinished(task);
|
|
514
536
|
for (const waiting of tasks.values())
|
|
@@ -580,7 +602,34 @@ function createTaskStore(opts) {
|
|
|
580
602
|
for (const id of resolveDeps(tasks, tokens))
|
|
581
603
|
acknowledge(id, false);
|
|
582
604
|
}
|
|
583
|
-
|
|
605
|
+
// The router stops tasks by title or id (castle-stop fence). Waiting tasks
|
|
606
|
+
// are cancelled outright; running ones get their agent process killed and
|
|
607
|
+
// finalize as interrupted via the stopRequested path.
|
|
608
|
+
function stop(tokens) {
|
|
609
|
+
for (const id of resolveDeps(tasks, tokens)) {
|
|
610
|
+
const task = tasks.get(id);
|
|
611
|
+
if (!task)
|
|
612
|
+
continue;
|
|
613
|
+
if (task.status === 'waiting') {
|
|
614
|
+
task.status = 'interrupted';
|
|
615
|
+
touch(task);
|
|
616
|
+
}
|
|
617
|
+
else if (task.status === 'running') {
|
|
618
|
+
stopRequested.add(task.id);
|
|
619
|
+
for (const child of children) {
|
|
620
|
+
if (child.pid === task.pid) {
|
|
621
|
+
try {
|
|
622
|
+
child.kill('SIGKILL');
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
/* already gone */
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return { sorted, get: (id) => tasks.get(id), spawnFromDirective, acknowledge, checkOff, stop, shutdown };
|
|
584
633
|
}
|
|
585
634
|
// -- attachments ----------------------------------------------------------------
|
|
586
635
|
const ATTACHMENT_MIME = {
|
|
@@ -756,9 +805,11 @@ function runRouterTurnIn(ctx, instruction) {
|
|
|
756
805
|
});
|
|
757
806
|
return;
|
|
758
807
|
}
|
|
759
|
-
const { cleaned, directives, checkoffs } = extractDirectives(result.finalText);
|
|
808
|
+
const { cleaned, directives, checkoffs, stops } = extractDirectives(result.finalText);
|
|
760
809
|
if (result.ok && checkoffs.length > 0)
|
|
761
810
|
ctx.taskStore.checkOff(checkoffs);
|
|
811
|
+
if (result.ok && stops.length > 0)
|
|
812
|
+
ctx.taskStore.stop(stops);
|
|
762
813
|
// Drop directives from stale turns, and any whose title matches a task
|
|
763
814
|
// already in flight (two runs reacting to the same ask).
|
|
764
815
|
const stale = epoch !== ctx.currentEpoch();
|
|
@@ -805,6 +856,62 @@ function applyAgentSettings(incoming, ctx) {
|
|
|
805
856
|
fs.writeFileSync(ctx.settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
806
857
|
ctx.broadcast({ type: 'settings', settings });
|
|
807
858
|
}
|
|
859
|
+
function killOrphanAgents(registryPath) {
|
|
860
|
+
const recorded = readJsonFile(registryPath) ?? [];
|
|
861
|
+
for (const entry of recorded) {
|
|
862
|
+
if (typeof entry?.pid !== 'number')
|
|
863
|
+
continue;
|
|
864
|
+
try {
|
|
865
|
+
const cmd = execFileSync('ps', ['-p', String(entry.pid), '-o', 'command='], {
|
|
866
|
+
encoding: 'utf8',
|
|
867
|
+
}).trim();
|
|
868
|
+
if (cmd.includes('cursor-agent') || cmd.includes('claude')) {
|
|
869
|
+
process.kill(entry.pid, 'SIGKILL');
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
/* pid not running anymore */
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
try {
|
|
877
|
+
fs.writeFileSync(registryPath, '[]\n');
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
/* registry dir missing -- created later */
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
function startChildRegistry(registryPath, groups) {
|
|
884
|
+
let last = '';
|
|
885
|
+
const timer = setInterval(() => {
|
|
886
|
+
const live = [];
|
|
887
|
+
for (const group of groups) {
|
|
888
|
+
for (const child of group) {
|
|
889
|
+
if (typeof child.pid === 'number' && child.exitCode === null) {
|
|
890
|
+
live.push({ pid: child.pid, command: child.spawnfile });
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
const snapshot = JSON.stringify(live);
|
|
895
|
+
if (snapshot === last)
|
|
896
|
+
return;
|
|
897
|
+
last = snapshot;
|
|
898
|
+
try {
|
|
899
|
+
fs.writeFileSync(registryPath, snapshot + '\n');
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
/* best effort */
|
|
903
|
+
}
|
|
904
|
+
}, 2_000);
|
|
905
|
+
return () => {
|
|
906
|
+
clearInterval(timer);
|
|
907
|
+
try {
|
|
908
|
+
fs.writeFileSync(registryPath, '[]\n');
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
/* best effort */
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
}
|
|
808
915
|
export function createAgentServer(opts) {
|
|
809
916
|
const { deckDir, deckLabel } = opts;
|
|
810
917
|
const agentDir = path.join(deckDir, '.castle', 'agent');
|
|
@@ -815,6 +922,11 @@ export function createAgentServer(opts) {
|
|
|
815
922
|
const taskChildren = new Set();
|
|
816
923
|
const routerChildren = new Set();
|
|
817
924
|
const clients = new Set();
|
|
925
|
+
// Kill agent processes orphaned by a previous serve that died uncleanly,
|
|
926
|
+
// then start tracking this serve's own children.
|
|
927
|
+
const childRegistryPath = path.join(agentDir, 'children.json');
|
|
928
|
+
killOrphanAgents(childRegistryPath);
|
|
929
|
+
const stopChildRegistry = startChildRegistry(childRegistryPath, [taskChildren, routerChildren]);
|
|
818
930
|
function broadcast(body) {
|
|
819
931
|
const payload = JSON.stringify(body);
|
|
820
932
|
for (const socket of clients) {
|
|
@@ -955,6 +1067,7 @@ export function createAgentServer(opts) {
|
|
|
955
1067
|
/* already gone */
|
|
956
1068
|
}
|
|
957
1069
|
}
|
|
1070
|
+
stopChildRegistry();
|
|
958
1071
|
wss.close();
|
|
959
1072
|
}
|
|
960
1073
|
return { handleUpgrade, handleHttpRequest, shutdown };
|
package/dist/serve.js
CHANGED
|
@@ -170,6 +170,9 @@ export async function serve(dir, options = {}) {
|
|
|
170
170
|
});
|
|
171
171
|
process.on('SIGINT', () => process.exit());
|
|
172
172
|
process.on('SIGTERM', () => process.exit());
|
|
173
|
+
// Terminal close: exit through the handlers so child agent processes die
|
|
174
|
+
// with the serve instead of orphaning.
|
|
175
|
+
process.on('SIGHUP', () => process.exit());
|
|
173
176
|
// The WS server forwards `restart` to the browser, but it also needs the
|
|
174
177
|
// Vite instance so it can drop transform caches first (see invalidateModuleCaches).
|
|
175
178
|
const viteHolder = { vite: null };
|