@chrysb/alphaclaw 0.9.27 → 0.9.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.
@@ -202,6 +202,9 @@ export const DoctorFindingsList = ({
202
202
  <${Badge} tone=${getDoctorCategoryTone(card.category)}>
203
203
  ${formatDoctorCategory(card.category)}
204
204
  </${Badge}>
205
+ ${card.status === "working"
206
+ ? html`<${Badge} tone="info">Working</${Badge}>`
207
+ : null}
205
208
  ${
206
209
  showRunMeta
207
210
  ? html`
@@ -314,8 +317,13 @@ export const DoctorFindingsList = ({
314
317
  <${ActionButton}
315
318
  onClick=${() => onAskAgentFix(card)}
316
319
  loading=${busyCardId === card.id}
320
+ disabled=${card.status !== "open"}
317
321
  tone="primary"
318
- idleLabel="Ask agent to fix"
322
+ idleLabel=${card.status === "working"
323
+ ? "Agent working..."
324
+ : card.status === "open"
325
+ ? "Ask agent to fix"
326
+ : "Reopen to send"}
319
327
  loadingLabel="Sending..."
320
328
  />
321
329
  ${
@@ -335,6 +343,15 @@ export const DoctorFindingsList = ({
335
343
  />
336
344
  `
337
345
  }
346
+ ${card.status === "working"
347
+ ? html`
348
+ <${ActionButton}
349
+ onClick=${() => onUpdateStatus(card, "open")}
350
+ tone="secondary"
351
+ idleLabel="Reopen"
352
+ />
353
+ `
354
+ : null}
338
355
  ${
339
356
  card.status !== "dismissed"
340
357
  ? html`
@@ -1,6 +1,6 @@
1
1
  import { h } from "preact";
2
2
  import htm from "htm";
3
- import { sendDoctorCardFix, updateDoctorCardStatus } from "../../lib/api.js";
3
+ import { sendDoctorCardFix } from "../../lib/api.js";
4
4
  import { showToast } from "../toast.js";
5
5
  import { AgentSendModal } from "../agent-send-modal.js";
6
6
 
@@ -12,29 +12,20 @@ export const DoctorFixCardModal = ({
12
12
  onClose = () => {},
13
13
  onComplete = () => {},
14
14
  }) => {
15
- const handleSend = async ({ selectedSession, message }) => {
15
+ const handleSend = async ({ selectedSession, selectedSessionKey, message }) => {
16
16
  if (!card?.id) return false;
17
17
  try {
18
18
  await sendDoctorCardFix({
19
19
  cardId: card.id,
20
- sessionId: selectedSession?.sessionId || "",
20
+ sessionKey: selectedSessionKey,
21
21
  replyChannel: selectedSession?.replyChannel || "",
22
22
  replyTo: selectedSession?.replyTo || "",
23
23
  prompt: message,
24
24
  });
25
- try {
26
- await updateDoctorCardStatus({ cardId: card.id, status: "fixed" });
27
- showToast(
28
- "Doctor fix request sent and finding marked fixed",
29
- "success",
30
- );
31
- } catch (statusError) {
32
- showToast(
33
- statusError.message ||
34
- "Doctor fix request sent, but could not mark the finding fixed",
35
- "warning",
36
- );
37
- }
25
+ showToast(
26
+ "Doctor fix queued. The agent will mark it fixed after applying and verifying the change.",
27
+ "success",
28
+ );
38
29
  await onComplete();
39
30
  return true;
40
31
  } catch (error) {
@@ -51,7 +42,7 @@ export const DoctorFixCardModal = ({
51
42
  initialMessage=${String(card?.fixPrompt || "")}
52
43
  resetKey=${String(card?.id || "")}
53
44
  submitLabel="Send fix request"
54
- loadingLabel="Sending..."
45
+ loadingLabel="Queuing..."
55
46
  onClose=${onClose}
56
47
  onSubmit=${handleSend}
57
48
  />
@@ -12,6 +12,7 @@ export const getDoctorStatusTone = (status = "") => {
12
12
  .trim()
13
13
  .toLowerCase();
14
14
  if (normalized === "fixed") return "success";
15
+ if (normalized === "working") return "info";
15
16
  if (normalized === "dismissed") return "neutral";
16
17
  return "warning";
17
18
  };
@@ -60,6 +61,10 @@ export const groupDoctorCardsByStatus = (cards = []) =>
60
61
  groups.fixed.push(card);
61
62
  return groups;
62
63
  }
64
+ if (status === "working") {
65
+ groups.working.push(card);
66
+ return groups;
67
+ }
63
68
  if (status === "dismissed") {
64
69
  groups.dismissed.push(card);
65
70
  return groups;
@@ -67,7 +72,7 @@ export const groupDoctorCardsByStatus = (cards = []) =>
67
72
  groups.open.push(card);
68
73
  return groups;
69
74
  },
70
- { open: [], dismissed: [], fixed: [] },
75
+ { open: [], working: [], dismissed: [], fixed: [] },
71
76
  );
72
77
 
73
78
  export const shouldShowDoctorWarning = (
@@ -185,6 +190,7 @@ export const buildDoctorRunMarkers = (run = null) => {
185
190
 
186
191
  export const buildDoctorStatusFilterOptions = () => [
187
192
  { value: "open", label: "Open" },
193
+ { value: "working", label: "Working" },
188
194
  { value: "dismissed", label: "Dismissed" },
189
195
  { value: "fixed", label: "Fixed" },
190
196
  ];
@@ -78,7 +78,7 @@ export const DoctorTab = ({ isActive = false, onOpenFile = () => {} }) => {
78
78
  status: "running",
79
79
  summary: "",
80
80
  priorityCounts: { P0: 0, P1: 0, P2: 0 },
81
- statusCounts: { open: 0, dismissed: 0, fixed: 0 },
81
+ statusCounts: { open: 0, working: 0, dismissed: 0, fixed: 0 },
82
82
  }
83
83
  : null;
84
84
  const displayRuns = pendingRun ? [pendingRun, ...runs] : runs;
@@ -300,7 +300,7 @@ export const updateDoctorCardStatus = async ({ cardId, status }) => {
300
300
 
301
301
  export const sendDoctorCardFix = async ({
302
302
  cardId,
303
- sessionId = "",
303
+ sessionKey = "",
304
304
  replyChannel = "",
305
305
  replyTo = "",
306
306
  prompt = "",
@@ -311,7 +311,7 @@ export const sendDoctorCardFix = async ({
311
311
  method: "POST",
312
312
  headers: { "Content-Type": "application/json" },
313
313
  body: JSON.stringify({
314
- sessionId: String(sessionId || ""),
314
+ sessionKey: String(sessionKey || ""),
315
315
  replyChannel: String(replyChannel || ""),
316
316
  replyTo: String(replyTo || ""),
317
317
  prompt: String(prompt || ""),
@@ -42,6 +42,7 @@ const buildPriorityCounts = (cards = []) => ({
42
42
 
43
43
  const buildStatusCounts = (cards = []) => ({
44
44
  open: cards.filter((card) => card.status === kDoctorCardStatus.open).length,
45
+ working: cards.filter((card) => card.status === kDoctorCardStatus.working).length,
45
46
  dismissed: cards.filter((card) => card.status === kDoctorCardStatus.dismissed).length,
46
47
  fixed: cards.filter((card) => card.status === kDoctorCardStatus.fixed).length,
47
48
  });
@@ -138,9 +139,10 @@ const listDoctorCards = ({ runId } = {}) => {
138
139
  ${hasRunFilter ? "WHERE c.run_id = $run_id" : ""}
139
140
  ORDER BY
140
141
  CASE c.status
141
- WHEN 'open' THEN 0
142
- WHEN 'dismissed' THEN 1
143
- ELSE 2
142
+ WHEN 'working' THEN 0
143
+ WHEN 'open' THEN 1
144
+ WHEN 'dismissed' THEN 2
145
+ ELSE 3
144
146
  END ASC,
145
147
  CASE c.priority
146
148
  WHEN 'P0' THEN 0
@@ -177,14 +179,15 @@ const toRunModel = (row) => {
177
179
  };
178
180
  };
179
181
 
180
- const initDoctorDb = ({ rootDir }) => {
182
+ const initDoctorDb = ({ rootDir, markInterruptedRuns = true }) => {
181
183
  closeDoctorDb();
182
184
  const dbDir = path.join(rootDir, "db");
183
185
  fs.mkdirSync(dbDir, { recursive: true });
184
186
  const dbPath = path.join(dbDir, "doctor.db");
185
187
  db = new DatabaseSync(dbPath);
188
+ db.exec("PRAGMA busy_timeout = 5000;");
186
189
  createSchema(db);
187
- markIncompleteRunsFailed();
190
+ if (markInterruptedRuns) markIncompleteRunsFailed();
188
191
  return { path: dbPath };
189
192
  };
190
193
 
@@ -507,12 +510,95 @@ const updateDoctorCardStatus = ({ id, status }) => {
507
510
  UPDATE doctor_cards
508
511
  SET
509
512
  status = $status,
513
+ fix_run_id = NULL,
514
+ fix_token_hash = NULL,
515
+ fix_started_at = NULL,
516
+ fix_completed_at = CASE WHEN $status = $fixed_status
517
+ THEN strftime('%Y-%m-%dT%H:%M:%fZ','now')
518
+ ELSE NULL
519
+ END,
510
520
  updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
511
521
  WHERE id = $id
512
522
  `)
513
523
  .run({
514
524
  $id: Number(id || 0),
515
525
  $status: nextStatus,
526
+ $fixed_status: kDoctorCardStatus.fixed,
527
+ });
528
+ return Number(result.changes || 0) > 0 ? getDoctorCard(id) : null;
529
+ };
530
+
531
+ const startDoctorCardFix = ({ id, runId, tokenHash }) => {
532
+ const database = ensureDb();
533
+ const result = database
534
+ .prepare(`
535
+ UPDATE doctor_cards
536
+ SET
537
+ status = $status,
538
+ fix_run_id = $run_id,
539
+ fix_token_hash = $token_hash,
540
+ fix_started_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
541
+ fix_completed_at = NULL,
542
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
543
+ WHERE id = $id
544
+ AND status = $open_status
545
+ `)
546
+ .run({
547
+ $id: Number(id || 0),
548
+ $status: kDoctorCardStatus.working,
549
+ $open_status: kDoctorCardStatus.open,
550
+ $run_id: String(runId || ""),
551
+ $token_hash: String(tokenHash || ""),
552
+ });
553
+ return Number(result.changes || 0) > 0 ? getDoctorCard(id) : null;
554
+ };
555
+
556
+ const cancelDoctorCardFix = ({ id, runId }) => {
557
+ const database = ensureDb();
558
+ const result = database
559
+ .prepare(`
560
+ UPDATE doctor_cards
561
+ SET
562
+ status = $status,
563
+ fix_run_id = NULL,
564
+ fix_token_hash = NULL,
565
+ fix_started_at = NULL,
566
+ fix_completed_at = NULL,
567
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
568
+ WHERE id = $id
569
+ AND status = $working_status
570
+ AND fix_run_id = $run_id
571
+ `)
572
+ .run({
573
+ $id: Number(id || 0),
574
+ $status: kDoctorCardStatus.open,
575
+ $working_status: kDoctorCardStatus.working,
576
+ $run_id: String(runId || ""),
577
+ });
578
+ return Number(result.changes || 0) > 0 ? getDoctorCard(id) : null;
579
+ };
580
+
581
+ const completeDoctorCardFix = ({ id, runId, tokenHash }) => {
582
+ const database = ensureDb();
583
+ const result = database
584
+ .prepare(`
585
+ UPDATE doctor_cards
586
+ SET
587
+ status = $status,
588
+ fix_token_hash = NULL,
589
+ fix_completed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
590
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
591
+ WHERE id = $id
592
+ AND status = $working_status
593
+ AND fix_run_id = $run_id
594
+ AND fix_token_hash = $token_hash
595
+ `)
596
+ .run({
597
+ $id: Number(id || 0),
598
+ $status: kDoctorCardStatus.fixed,
599
+ $working_status: kDoctorCardStatus.working,
600
+ $run_id: String(runId || ""),
601
+ $token_hash: String(tokenHash || ""),
516
602
  });
517
603
  return Number(result.changes || 0) > 0 ? getDoctorCard(id) : null;
518
604
  };
@@ -535,4 +621,7 @@ module.exports = {
535
621
  getDoctorCardsByRunId: getCardsByRunId,
536
622
  getDoctorCard,
537
623
  updateDoctorCardStatus,
624
+ startDoctorCardFix,
625
+ cancelDoctorCardFix,
626
+ completeDoctorCardFix,
538
627
  };
@@ -58,6 +58,10 @@ const createSchema = (database) => {
58
58
  ensureColumn(database, "doctor_runs", "workspace_fingerprint", "TEXT");
59
59
  ensureColumn(database, "doctor_runs", "workspace_manifest_json", "TEXT");
60
60
  ensureColumn(database, "doctor_runs", "reused_from_run_id", "INTEGER");
61
+ ensureColumn(database, "doctor_cards", "fix_run_id", "TEXT");
62
+ ensureColumn(database, "doctor_cards", "fix_token_hash", "TEXT");
63
+ ensureColumn(database, "doctor_cards", "fix_started_at", "TEXT");
64
+ ensureColumn(database, "doctor_cards", "fix_completed_at", "TEXT");
61
65
  database.exec(`
62
66
  CREATE INDEX IF NOT EXISTS idx_doctor_cards_run_id
63
67
  ON doctor_cards(run_id, created_at DESC);
@@ -6,6 +6,7 @@ const kDoctorRunStatus = {
6
6
  };
7
7
  const kDoctorCardStatus = {
8
8
  open: "open",
9
+ working: "working",
9
10
  dismissed: "dismissed",
10
11
  fixed: "fixed",
11
12
  };
@@ -0,0 +1,6 @@
1
+ const crypto = require("crypto");
2
+
3
+ const hashDoctorFixToken = (token) =>
4
+ crypto.createHash("sha256").update(String(token || "")).digest("hex");
5
+
6
+ module.exports = { hashDoctorFixToken };
@@ -1,3 +1,4 @@
1
+ const crypto = require("crypto");
1
2
  const fs = require("fs");
2
3
  const path = require("path");
3
4
  const {
@@ -5,9 +6,11 @@ const {
5
6
  buildBootstrapTruncationCards,
6
7
  } = require("./bootstrap-context");
7
8
  const { buildDoctorPrompt } = require("./prompt");
9
+ const { hashDoctorFixToken } = require("./fix-completion");
8
10
  const { normalizeDoctorResult } = require("./normalize");
9
11
  const { calculateWorkspaceDelta, computeWorkspaceSnapshot } = require("./workspace-fingerprint");
10
12
  const {
13
+ kDoctorCardStatus,
11
14
  kDoctorEngine,
12
15
  kDoctorMeaningfulChangeScoreThreshold,
13
16
  kDoctorPromptVersion,
@@ -17,6 +20,35 @@ const {
17
20
  } = require("./constants");
18
21
 
19
22
  const kMaxSnippetLines = 20;
23
+ const kDoctorFixDispatchTimeoutMs = 30000;
24
+
25
+ const buildDoctorFixCompletionInstructions = ({
26
+ cardId,
27
+ runId,
28
+ token,
29
+ alphaclawRootDir,
30
+ }) => {
31
+ const completionCommand = [
32
+ "alphaclaw",
33
+ "--root-dir",
34
+ shellEscapeArg(alphaclawRootDir),
35
+ "doctor finding complete",
36
+ "--id",
37
+ shellEscapeArg(String(Number(cardId || 0))),
38
+ "--run",
39
+ shellEscapeArg(runId),
40
+ "--token",
41
+ shellEscapeArg(token),
42
+ ].join(" ");
43
+ return [
44
+ "AlphaClaw completion callback:",
45
+ "After you have successfully applied and verified the requested fix, run this command exactly:",
46
+ "```sh",
47
+ completionCommand,
48
+ "```",
49
+ "Do not call the completion callback if the fix was not applied or verification failed. Explain the problem to the user instead.",
50
+ ].join("\n");
51
+ };
20
52
 
21
53
  const shellEscapeArg = (value) => {
22
54
  const safeValue = String(value || "");
@@ -91,8 +123,11 @@ const createDoctorService = ({
91
123
  getDoctorCardsByRunId,
92
124
  getDoctorCard,
93
125
  updateDoctorCardStatus,
126
+ startDoctorCardFix,
127
+ cancelDoctorCardFix,
94
128
  workspaceRoot,
95
129
  managedRoot,
130
+ alphaclawRootDir = process.env.ALPHACLAW_ROOT_DIR || "~/.alphaclaw",
96
131
  protectedPaths = [],
97
132
  lockedPaths = [],
98
133
  }) => {
@@ -133,7 +168,13 @@ const createDoctorService = ({
133
168
  };
134
169
 
135
170
  const cloneRunCards = ({ sourceRunId, targetRunId }) => {
136
- const sourceCards = getDoctorCardsByRunId(sourceRunId);
171
+ const sourceCards = getDoctorCardsByRunId(sourceRunId).map((card) => ({
172
+ ...card,
173
+ status:
174
+ card.status === kDoctorCardStatus.working
175
+ ? kDoctorCardStatus.open
176
+ : card.status,
177
+ }));
137
178
  insertDoctorCards({
138
179
  runId: targetRunId,
139
180
  cards: sourceCards,
@@ -385,7 +426,7 @@ const createDoctorService = ({
385
426
 
386
427
  const requestCardFix = async ({
387
428
  cardId,
388
- sessionId = "",
429
+ sessionKey = "",
389
430
  replyChannel = "",
390
431
  replyTo = "",
391
432
  prompt = "",
@@ -394,28 +435,65 @@ const createDoctorService = ({
394
435
  if (!card) throw new Error("Doctor card not found");
395
436
  const resolvedPrompt = String(prompt || card.fixPrompt || "").trim();
396
437
  if (!resolvedPrompt) throw new Error("Doctor card does not include a fix prompt");
397
- let command = `agent --agent main --message ${shellEscapeArg(resolvedPrompt)}`;
398
- const trimmedSessionId = String(sessionId || "").trim();
438
+ const trimmedSessionKey = String(sessionKey || "").trim();
439
+ if (!trimmedSessionKey) throw new Error("Doctor fix request requires a session key");
399
440
  const trimmedReplyChannel = String(replyChannel || "").trim();
400
441
  const trimmedReplyTo = String(replyTo || "").trim();
401
- if (trimmedReplyChannel && trimmedReplyTo) {
402
- command +=
403
- ` --deliver --reply-channel ${shellEscapeArg(trimmedReplyChannel)}` +
404
- ` --reply-to ${shellEscapeArg(trimmedReplyTo)}`;
405
- } else if (trimmedSessionId) {
406
- command += ` --session-id ${shellEscapeArg(trimmedSessionId)}`;
442
+ const runId = `doctor-fix-${Number(card.id || cardId)}-${crypto.randomUUID()}`;
443
+ const callbackToken = crypto.randomBytes(32).toString("hex");
444
+ const workingCard = startDoctorCardFix({
445
+ id: card.id,
446
+ runId,
447
+ tokenHash: hashDoctorFixToken(callbackToken),
448
+ });
449
+ if (!workingCard) {
450
+ const currentCard = getDoctorCard(card.id);
451
+ if (currentCard?.status === kDoctorCardStatus.working) {
452
+ throw new Error("Doctor fix already in progress");
453
+ }
454
+ throw new Error("Doctor finding must be open before requesting a fix");
407
455
  }
408
- const result = await clawCmd(command, {
409
- quiet: true,
410
- timeoutMs: kDoctorRunTimeoutMs,
456
+ const completionInstructions = buildDoctorFixCompletionInstructions({
457
+ cardId: card.id,
458
+ runId,
459
+ token: callbackToken,
460
+ alphaclawRootDir,
411
461
  });
462
+ const gatewayParams = {
463
+ idempotencyKey: runId,
464
+ message: `${resolvedPrompt}\n\n${completionInstructions}`,
465
+ sessionKey: trimmedSessionKey,
466
+ };
467
+ if (trimmedReplyChannel && trimmedReplyTo) {
468
+ gatewayParams.deliver = true;
469
+ gatewayParams.replyChannel = trimmedReplyChannel;
470
+ gatewayParams.replyTo = trimmedReplyTo;
471
+ }
472
+ let result = null;
473
+ try {
474
+ result = await clawCmd(
475
+ `gateway call agent --json --timeout ${kDoctorFixDispatchTimeoutMs} --params ${shellEscapeArg(
476
+ JSON.stringify(gatewayParams),
477
+ )}`,
478
+ {
479
+ quiet: true,
480
+ timeoutMs: kDoctorFixDispatchTimeoutMs,
481
+ },
482
+ );
483
+ } catch (error) {
484
+ cancelDoctorCardFix({ id: card.id, runId });
485
+ throw error;
486
+ }
412
487
  if (!result?.ok) {
488
+ cancelDoctorCardFix({ id: card.id, runId });
413
489
  throw new Error(result?.stderr || "Could not send Doctor fix request");
414
490
  }
415
491
  return {
416
492
  ok: true,
493
+ queued: true,
494
+ runId,
417
495
  stdout: result.stdout || "",
418
- card,
496
+ card: workingCard,
419
497
  };
420
498
  };
421
499
 
@@ -443,6 +521,7 @@ const createDoctorService = ({
443
521
  };
444
522
 
445
523
  module.exports = {
524
+ buildDoctorFixCompletionInstructions,
446
525
  buildDoctorIdempotencyKey,
447
526
  buildDoctorSessionKey,
448
527
  buildDoctorSessionId,
@@ -106,13 +106,16 @@ const registerDoctorRoutes = ({ app, requireAuth, doctorService }) => {
106
106
  try {
107
107
  const result = await doctorService.requestCardFix({
108
108
  cardId: req.params.id,
109
- sessionId: req.body?.sessionId,
109
+ sessionKey: req.body?.sessionKey,
110
110
  replyChannel: req.body?.replyChannel,
111
111
  replyTo: req.body?.replyTo,
112
112
  prompt: req.body?.prompt,
113
113
  });
114
- return res.json(result);
114
+ return res.status(result?.queued ? 202 : 200).json(result);
115
115
  } catch (error) {
116
+ if (/already in progress|must be open/i.test(error.message || "")) {
117
+ return res.status(409).json({ ok: false, error: error.message });
118
+ }
116
119
  if (/not found/i.test(error.message || "")) {
117
120
  return res.status(404).json({ ok: false, error: error.message });
118
121
  }
package/lib/server.js CHANGED
@@ -64,6 +64,8 @@ const {
64
64
  getDoctorCardsByRunId,
65
65
  getDoctorCard,
66
66
  updateDoctorCardStatus,
67
+ startDoctorCardFix,
68
+ cancelDoctorCardFix,
67
69
  } = require("./server/db/doctor");
68
70
  const {
69
71
  initAuthDb,
@@ -306,8 +308,11 @@ const doctorService = createDoctorService({
306
308
  getDoctorCardsByRunId,
307
309
  getDoctorCard,
308
310
  updateDoctorCardStatus,
311
+ startDoctorCardFix,
312
+ cancelDoctorCardFix,
309
313
  workspaceRoot: constants.WORKSPACE_DIR,
310
314
  managedRoot: constants.OPENCLAW_DIR,
315
+ alphaclawRootDir: constants.kRootDir,
311
316
  protectedPaths: Array.from(constants.kProtectedBrowsePaths || []),
312
317
  lockedPaths: Array.from(constants.kLockedBrowsePaths || []),
313
318
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.9.27",
3
+ "version": "0.9.28",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },