@chrysb/alphaclaw 0.4.6-beta.6 → 0.4.6-beta.8

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.
@@ -6,8 +6,10 @@ import {
6
6
  createWebhook,
7
7
  deleteWebhook,
8
8
  fetchWebhookDetail,
9
+ fetchWebhookRequest,
9
10
  fetchWebhookRequests,
10
11
  fetchWebhooks,
12
+ sendAgentMessage,
11
13
  } from "../lib/api.js";
12
14
  import {
13
15
  formatLocaleDateTime,
@@ -17,6 +19,7 @@ import { showToast } from "./toast.js";
17
19
  import { PageHeader } from "./page-header.js";
18
20
  import { ConfirmDialog } from "./confirm-dialog.js";
19
21
  import { ActionButton } from "./action-button.js";
22
+ import { AgentSendModal } from "./agent-send-modal.js";
20
23
  import { ModalShell } from "./modal-shell.js";
21
24
  import { Badge } from "./badge.js";
22
25
  import { CloseIcon } from "./icons.js";
@@ -82,6 +85,40 @@ const jsonPretty = (value) => {
82
85
  }
83
86
  };
84
87
 
88
+ const buildWebhookDebugMessage = ({
89
+ hookName = "",
90
+ webhook = null,
91
+ request = null,
92
+ }) => {
93
+ const hookPath =
94
+ String(webhook?.path || "").trim() ||
95
+ (hookName ? `/hooks/${hookName}` : "/hooks/unknown");
96
+ const gatewayStatus =
97
+ request?.gatewayStatus == null ? "n/a" : String(request.gatewayStatus);
98
+ return [
99
+ "Investigate this failed webhook request and share findings before fixing anything.",
100
+ "Reply with your diagnosis first, including the likely root cause, any relevant risks, and what you would change if I approve a fix.",
101
+ "",
102
+ `Webhook: ${hookPath}`,
103
+ `Request ID: ${String(request?.id || "unknown")}`,
104
+ `Time: ${String(request?.createdAt || "unknown")}`,
105
+ `Method: ${String(request?.method || "unknown")}`,
106
+ `Source IP: ${String(request?.sourceIp || "unknown")}`,
107
+ `Gateway status: ${gatewayStatus}`,
108
+ `Transform path: ${String(webhook?.transformPath || "unknown")}`,
109
+ `Payload truncated: ${request?.payloadTruncated ? "yes" : "no"}`,
110
+ "",
111
+ "Headers:",
112
+ jsonPretty(request?.headers),
113
+ "",
114
+ "Payload:",
115
+ jsonPretty(request?.payload),
116
+ "",
117
+ "Gateway response:",
118
+ jsonPretty(request?.gatewayBody),
119
+ ].join("\n");
120
+ };
121
+
85
122
  const CreateWebhookModal = ({
86
123
  visible,
87
124
  name,
@@ -197,6 +234,8 @@ export const Webhooks = ({
197
234
  const [expandedRows, setExpandedRows] = useState(() => new Set());
198
235
  const [sendingTestWebhook, setSendingTestWebhook] = useState(false);
199
236
  const [replayingRequestId, setReplayingRequestId] = useState(null);
237
+ const [debugLoadingRequestId, setDebugLoadingRequestId] = useState(null);
238
+ const [debugRequest, setDebugRequest] = useState(null);
200
239
 
201
240
  const listPoll = usePolling(fetchWebhooks, 15000);
202
241
  const webhooks = listPoll.data?.webhooks || [];
@@ -496,6 +535,31 @@ export const Webhooks = ({
496
535
  }, []);
497
536
 
498
537
  const isListLoading = !listPoll.data && !listPoll.error;
538
+ const debugAgentMessage = useMemo(
539
+ () =>
540
+ buildWebhookDebugMessage({
541
+ hookName: selectedHookName,
542
+ webhook: selectedWebhook,
543
+ request: debugRequest,
544
+ }),
545
+ [debugRequest, selectedHookName, selectedWebhook],
546
+ );
547
+
548
+ const handleAskAgentToDebug = useCallback(
549
+ async (item) => {
550
+ if (!selectedHookName || !item?.id || debugLoadingRequestId === item.id) return;
551
+ try {
552
+ setDebugLoadingRequestId(item.id);
553
+ const data = await fetchWebhookRequest(selectedHookName, item.id);
554
+ setDebugRequest(data?.request || item);
555
+ } catch (err) {
556
+ showToast(err.message || "Could not load webhook request details", "error");
557
+ } finally {
558
+ setDebugLoadingRequestId(null);
559
+ }
560
+ },
561
+ [debugLoadingRequestId, selectedHookName],
562
+ );
499
563
 
500
564
  return html`
501
565
  <div class="space-y-4">
@@ -841,7 +905,7 @@ export const Webhooks = ({
841
905
  >
842
906
  ${jsonPretty(item.headers)}</pre
843
907
  >
844
- <div class="mt-2 flex justify-start">
908
+ <div class="mt-2 flex justify-start gap-2">
845
909
  <button
846
910
  class="h-7 text-xs px-2.5 rounded-lg ac-btn-secondary"
847
911
  onclick=${() =>
@@ -905,7 +969,7 @@ ${jsonPretty(item.payload)}</pre
905
969
  >
906
970
  ${jsonPretty(item.gatewayBody)}</pre
907
971
  >
908
- <div class="mt-2 flex justify-start">
972
+ <div class="mt-2 flex justify-start gap-2">
909
973
  <button
910
974
  class="h-7 text-xs px-2.5 rounded-lg ac-btn-secondary"
911
975
  onclick=${() =>
@@ -916,6 +980,19 @@ ${jsonPretty(item.gatewayBody)}</pre
916
980
  >
917
981
  Copy
918
982
  </button>
983
+ ${item.status === "error"
984
+ ? html`<${ActionButton}
985
+ onClick=${() =>
986
+ handleAskAgentToDebug(item)}
987
+ loading=${debugLoadingRequestId ===
988
+ item.id}
989
+ tone="primary"
990
+ size="sm"
991
+ idleLabel="Ask agent to debug"
992
+ loadingLabel="Loading..."
993
+ className="h-7 px-2.5"
994
+ />`
995
+ : null}
919
996
  </div>
920
997
  </div>
921
998
  </div>
@@ -1058,6 +1135,30 @@ ${jsonPretty(item.gatewayBody)}</pre
1058
1135
  }}
1059
1136
  onConfirm=${handleDeleteConfirmed}
1060
1137
  />
1138
+ <${AgentSendModal}
1139
+ visible=${!!debugRequest}
1140
+ title="Ask agent to debug"
1141
+ messageLabel="Debug request"
1142
+ messageRows=${12}
1143
+ initialMessage=${debugAgentMessage}
1144
+ resetKey=${String(debugRequest?.id || "")}
1145
+ submitLabel="Send debug request"
1146
+ loadingLabel="Sending..."
1147
+ onClose=${() => setDebugRequest(null)}
1148
+ onSubmit=${async ({ selectedSessionKey, message }) => {
1149
+ try {
1150
+ await sendAgentMessage({
1151
+ message,
1152
+ sessionKey: selectedSessionKey,
1153
+ });
1154
+ showToast("Debug request sent to agent", "success");
1155
+ return true;
1156
+ } catch (err) {
1157
+ showToast(err.message || "Could not send debug request", "error");
1158
+ return false;
1159
+ }
1160
+ }}
1161
+ />
1061
1162
  </div>
1062
1163
  `;
1063
1164
  };
@@ -6,6 +6,7 @@ const { parsePositiveInt } = require("./utils/number");
6
6
  // Portable root directory: --root-dir flag sets ALPHACLAW_ROOT_DIR before require
7
7
  const kRootDir =
8
8
  process.env.ALPHACLAW_ROOT_DIR || path.join(os.homedir(), ".alphaclaw");
9
+ const ALPHACLAW_DIR = kRootDir;
9
10
  const kPackageRoot = path.resolve(__dirname, "..");
10
11
  const kNpmPackageRoot = path.resolve(kPackageRoot, "..");
11
12
  const kSetupDir = path.join(kPackageRoot, "setup");
@@ -18,6 +19,8 @@ const OPENCLAW_DIR = path.join(kRootDir, ".openclaw");
18
19
  const GATEWAY_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN || "";
19
20
  const ENV_FILE_PATH = path.join(kRootDir, ".env");
20
21
  const WORKSPACE_DIR = path.join(OPENCLAW_DIR, "workspace");
22
+ const kOnboardingMarkerPath = path.join(ALPHACLAW_DIR, "onboarded.json");
23
+ const kControlUiSkillPath = path.join(OPENCLAW_DIR, "skills", "control-ui", "SKILL.md");
21
24
  const AUTH_PROFILES_PATH = path.join(
22
25
  OPENCLAW_DIR,
23
26
  "agents",
@@ -346,6 +349,7 @@ const SETUP_API_PREFIXES = [
346
349
  ];
347
350
 
348
351
  module.exports = {
352
+ ALPHACLAW_DIR,
349
353
  kRootDir,
350
354
  kPackageRoot,
351
355
  kNpmPackageRoot,
@@ -358,6 +362,8 @@ module.exports = {
358
362
  GATEWAY_TOKEN,
359
363
  ENV_FILE_PATH,
360
364
  WORKSPACE_DIR,
365
+ kOnboardingMarkerPath,
366
+ kControlUiSkillPath,
361
367
  AUTH_PROFILES_PATH,
362
368
  CODEX_PROFILE_ID,
363
369
  CODEX_OAUTH_CLIENT_ID,
@@ -0,0 +1,191 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ const kDoctorBootstrapMaxChars = 20000;
5
+ const kDoctorBootstrapTotalMaxChars = 150000;
6
+ const kDoctorBootstrapNearLimitRatio = 0.9;
7
+ const kDoctorContextTruncationGuidance =
8
+ "OpenClaw trims oversized injected files by keeping the first 70%, keeping the last 20%, and cutting the middle 10% without a warning.";
9
+
10
+ const kDoctorRootContextFiles = [
11
+ { path: "AGENTS.md", injectMode: "always" },
12
+ { path: "SOUL.md", injectMode: "always" },
13
+ { path: "TOOLS.md", injectMode: "always" },
14
+ { path: "IDENTITY.md", injectMode: "always" },
15
+ { path: "USER.md", injectMode: "always" },
16
+ { path: "HEARTBEAT.md", injectMode: "always" },
17
+ { path: "BOOTSTRAP.md", injectMode: "first_run_only" },
18
+ ];
19
+
20
+ const kDoctorBootstrapExtraFiles = [
21
+ { path: "hooks/bootstrap/AGENTS.md", injectMode: "always" },
22
+ { path: "hooks/bootstrap/TOOLS.md", injectMode: "always" },
23
+ ];
24
+
25
+ const kDoctorBootstrapContextFiles = [...kDoctorRootContextFiles, ...kDoctorBootstrapExtraFiles];
26
+
27
+ const readWorkspaceFileChars = (workspaceRoot, relativePath) => {
28
+ const fullPath = path.join(workspaceRoot, relativePath);
29
+ try {
30
+ const content = fs.readFileSync(fullPath, "utf8");
31
+ return {
32
+ exists: true,
33
+ chars: content.length,
34
+ };
35
+ } catch {
36
+ return {
37
+ exists: false,
38
+ chars: 0,
39
+ };
40
+ }
41
+ };
42
+
43
+ const analyzeBootstrapContext = ({
44
+ workspaceRoot = "",
45
+ bootstrapMaxChars = kDoctorBootstrapMaxChars,
46
+ bootstrapTotalMaxChars = kDoctorBootstrapTotalMaxChars,
47
+ } = {}) => {
48
+ const files = kDoctorBootstrapContextFiles.map((spec) => {
49
+ const fileState = readWorkspaceFileChars(workspaceRoot, spec.path);
50
+ const rawChars = fileState.chars;
51
+ const fileLimitChars = Math.min(rawChars, bootstrapMaxChars);
52
+ const nearFileLimit = rawChars > 0 && rawChars >= Math.floor(bootstrapMaxChars * kDoctorBootstrapNearLimitRatio);
53
+ return {
54
+ ...spec,
55
+ exists: fileState.exists,
56
+ rawChars,
57
+ fileLimitChars,
58
+ injectedChars: 0,
59
+ truncatedByFileLimit: rawChars > bootstrapMaxChars,
60
+ truncatedByTotalLimit: false,
61
+ truncated: rawChars > bootstrapMaxChars,
62
+ nearFileLimit: nearFileLimit && rawChars <= bootstrapMaxChars,
63
+ active: spec.injectMode === "always",
64
+ reason: rawChars > bootstrapMaxChars ? "file_limit" : "",
65
+ };
66
+ });
67
+
68
+ let injectedTotalChars = 0;
69
+ for (const file of files) {
70
+ if (!file.active || !file.exists) continue;
71
+ const remainingChars = Math.max(0, bootstrapTotalMaxChars - injectedTotalChars);
72
+ file.injectedChars = Math.min(file.fileLimitChars, remainingChars);
73
+ file.truncatedByTotalLimit = file.fileLimitChars > file.injectedChars;
74
+ file.truncated = file.truncatedByFileLimit || file.truncatedByTotalLimit;
75
+ if (file.truncatedByFileLimit && file.truncatedByTotalLimit) {
76
+ file.reason = "file_and_total_limit";
77
+ } else if (file.truncatedByFileLimit) {
78
+ file.reason = "file_limit";
79
+ } else if (file.truncatedByTotalLimit) {
80
+ file.reason = "total_limit";
81
+ }
82
+ injectedTotalChars += file.injectedChars;
83
+ }
84
+
85
+ const activeFiles = files.filter((file) => file.active && file.exists);
86
+ const activeTruncatedFiles = activeFiles.filter((file) => file.truncated);
87
+ const activeNearLimitFiles = activeFiles.filter((file) => file.nearFileLimit && !file.truncated);
88
+ const inactiveTruncatedFiles = files.filter((file) => !file.active && file.exists && file.truncated);
89
+ const hasTotalLimitTruncation = activeTruncatedFiles.some(
90
+ (file) => file.reason === "total_limit" || file.reason === "file_and_total_limit",
91
+ );
92
+
93
+ return {
94
+ bootstrapMaxChars,
95
+ bootstrapTotalMaxChars,
96
+ truncationGuidance: kDoctorContextTruncationGuidance,
97
+ files,
98
+ activeFiles,
99
+ activeRawChars: activeFiles.reduce((sum, file) => sum + file.rawChars, 0),
100
+ activeInjectedChars: activeFiles.reduce((sum, file) => sum + file.injectedChars, 0),
101
+ hasActiveTruncation: activeTruncatedFiles.length > 0,
102
+ hasActiveNearLimitFiles: activeNearLimitFiles.length > 0,
103
+ hasActiveWarnings: activeTruncatedFiles.length > 0 || activeNearLimitFiles.length > 0,
104
+ hasAnyTruncation: activeTruncatedFiles.length > 0 || inactiveTruncatedFiles.length > 0,
105
+ activeTruncatedFiles,
106
+ activeNearLimitFiles,
107
+ inactiveTruncatedFiles,
108
+ hasTotalLimitTruncation,
109
+ totalLimitReached: injectedTotalChars >= bootstrapTotalMaxChars,
110
+ };
111
+ };
112
+
113
+ const formatChars = (value = 0) => `${Number(value || 0).toLocaleString()} chars`;
114
+
115
+ const buildBootstrapTruncationCards = (bootstrapContext = null) => {
116
+ if (!bootstrapContext?.hasActiveTruncation) return [];
117
+
118
+ const cards = bootstrapContext.activeTruncatedFiles
119
+ .filter((file) => file.reason === "file_limit")
120
+ .map((file) => ({
121
+ priority: "P0",
122
+ category: "project context",
123
+ title: `${file.path} is being truncated in Project Context`,
124
+ summary:
125
+ `${file.path} is ${formatChars(file.rawChars)}, above the per-file Project Context limit ` +
126
+ `of ${formatChars(bootstrapContext.bootstrapMaxChars)}. The agent is not seeing the full file.`,
127
+ recommendation:
128
+ `Move the most important rules to the top of ${file.path}, shorten or split low-priority content, ` +
129
+ `and increase OpenClaw's bootstrap limits if this file legitimately needs more room. ` +
130
+ kDoctorContextTruncationGuidance,
131
+ evidence: [
132
+ { type: "path", path: file.path },
133
+ {
134
+ type: "text",
135
+ text:
136
+ `Raw size: ${formatChars(file.rawChars)}. ` +
137
+ `Per-file limit: ${formatChars(bootstrapContext.bootstrapMaxChars)}.`,
138
+ },
139
+ ],
140
+ targetPaths: [{ path: file.path }],
141
+ fixPrompt:
142
+ `Reorganize ${file.path} so the most important instructions appear at the top and reduce unnecessary length. ` +
143
+ `Do not change unrelated behavior.`,
144
+ status: "open",
145
+ }));
146
+
147
+ const totalLimitedFiles = bootstrapContext.activeTruncatedFiles.filter(
148
+ (file) => file.reason === "total_limit" || file.reason === "file_and_total_limit",
149
+ );
150
+ if (totalLimitedFiles.length > 0) {
151
+ cards.unshift({
152
+ priority: "P0",
153
+ category: "project context",
154
+ title: "Project Context total bootstrap limit is truncating injected files",
155
+ summary:
156
+ `Injected workspace guidance needs ${formatChars(bootstrapContext.activeRawChars)} raw across active ` +
157
+ `Project Context files, exceeding the total bootstrap budget of ` +
158
+ `${formatChars(bootstrapContext.bootstrapTotalMaxChars)}.`,
159
+ recommendation:
160
+ `Reduce total Project Context size across injected guidance files, keep critical instructions near the top, ` +
161
+ `and raise OpenClaw's total bootstrap budget if the workspace legitimately needs more injected guidance. ` +
162
+ kDoctorContextTruncationGuidance,
163
+ evidence: totalLimitedFiles.map((file) => ({
164
+ type: "text",
165
+ text:
166
+ `${file.path}: raw ${formatChars(file.rawChars)}, injected ${formatChars(file.injectedChars)} ` +
167
+ `before the total limit stopped more content from being included.`,
168
+ })),
169
+ targetPaths: totalLimitedFiles.map((file) => ({ path: file.path })),
170
+ fixPrompt:
171
+ `Reduce the combined size of the affected Project Context files and keep the most important instructions near the top. ` +
172
+ `Only edit the files listed in the finding.`,
173
+ status: "open",
174
+ });
175
+ }
176
+
177
+ return cards;
178
+ };
179
+
180
+ module.exports = {
181
+ analyzeBootstrapContext,
182
+ buildBootstrapTruncationCards,
183
+ formatChars,
184
+ kDoctorBootstrapContextFiles,
185
+ kDoctorBootstrapExtraFiles,
186
+ kDoctorBootstrapMaxChars,
187
+ kDoctorBootstrapNearLimitRatio,
188
+ kDoctorBootstrapTotalMaxChars,
189
+ kDoctorContextTruncationGuidance,
190
+ kDoctorRootContextFiles,
191
+ };
@@ -1,6 +1,17 @@
1
+ const {
2
+ kDoctorBootstrapExtraFiles,
3
+ kDoctorBootstrapMaxChars,
4
+ kDoctorBootstrapTotalMaxChars,
5
+ kDoctorContextTruncationGuidance,
6
+ kDoctorRootContextFiles,
7
+ } = require("./bootstrap-context");
8
+
1
9
  const renderList = (items = []) =>
2
10
  items.length ? items.map((item) => `- ${item}`).join("\n") : "- (none)";
3
11
 
12
+ const renderContextFileList = (files = []) =>
13
+ files.map((file) => `\`${file.path}\``).join(", ");
14
+
4
15
  const renderResolvedCards = (cards = []) => {
5
16
  if (!cards.length) return "";
6
17
  const lines = cards.map(
@@ -37,10 +48,15 @@ Important:
37
48
  - Return ONLY valid JSON. No markdown fences. No extra prose.
38
49
 
39
50
  OpenClaw context injection:
40
- - OpenClaw automatically injects a fixed set of named workspace files into the agent's context window ("Project Context") on every turn. The exact set is: \`AGENTS.md\`, \`SOUL.md\`, \`TOOLS.md\`, \`IDENTITY.md\`, \`USER.md\`, \`HEARTBEAT.md\`, and \`BOOTSTRAP.md\` (first-run only).
41
- - Only these specific files are auto-injected. Other \`.md\` files at the workspace root (e.g. INTERESTS.md, MEMORY.md, README.md) are NOT injected and must be explicitly read by the agent.
42
- - Do not flag auto-injected files as orphaned or unreferenced — they are loaded by the runtime, not by explicit file references in AGENTS.md.
43
- - Additionally, AlphaClaw injects bootstrap files from \`hooks/bootstrap/\` (e.g. AGENTS.md, TOOLS.md) as extra context on every turn.
51
+ - OpenClaw automatically injects these workspace files into the agent's Project Context: ${renderContextFileList(
52
+ kDoctorRootContextFiles,
53
+ )}.
54
+ - \`BOOTSTRAP.md\` is first-run only; the others above are injected on normal turns when present.
55
+ - Additionally, AlphaClaw injects these extra bootstrap files on normal turns when present: ${renderContextFileList(
56
+ kDoctorBootstrapExtraFiles,
57
+ )}.
58
+ - Large injected files are truncated per-file at ${kDoctorBootstrapMaxChars} chars by default, and total bootstrap injection across files is capped at ${kDoctorBootstrapTotalMaxChars} chars by default.
59
+ - ${kDoctorContextTruncationGuidance}
44
60
 
45
61
  OpenClaw default context:
46
62
  - \`AGENTS.md\` is the workspace home file in the default OpenClaw template. It may intentionally include first-run instructions, session-startup guidance, memory conventions, safety rules, tool pointers, and optional behavioral guidance.
@@ -1,5 +1,9 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
+ const {
4
+ analyzeBootstrapContext,
5
+ buildBootstrapTruncationCards,
6
+ } = require("./bootstrap-context");
3
7
  const { buildDoctorPrompt } = require("./prompt");
4
8
  const { normalizeDoctorResult } = require("./normalize");
5
9
  const { calculateWorkspaceDelta, computeWorkspaceSnapshot } = require("./workspace-fingerprint");
@@ -137,6 +141,7 @@ const createDoctorService = ({
137
141
  };
138
142
 
139
143
  const buildStatus = () => {
144
+ const bootstrapContext = analyzeBootstrapContext({ workspaceRoot });
140
145
  const recentRuns = listDoctorRuns({ limit: 10 });
141
146
  const latestRun = recentRuns[0] || null;
142
147
  const latestCompletedRun =
@@ -179,6 +184,7 @@ const createDoctorService = ({
179
184
  lastRunAgeMs,
180
185
  needsInitialRun: !latestCompletedRun,
181
186
  stale,
187
+ bootstrapContext,
182
188
  changeSummary: {
183
189
  ...delta,
184
190
  hasBaseline: hasManifestBaseline,
@@ -245,10 +251,14 @@ const createDoctorService = ({
245
251
  console.error(`[doctor] run ${runId} stderr end`);
246
252
  throw error;
247
253
  }
248
- captureEvidenceSnippets(normalizedResult.cards, workspaceRoot);
254
+ const bootstrapTruncationCards = buildBootstrapTruncationCards(
255
+ analyzeBootstrapContext({ workspaceRoot }),
256
+ );
257
+ const cards = [...bootstrapTruncationCards, ...normalizedResult.cards];
258
+ captureEvidenceSnippets(cards, workspaceRoot);
249
259
  insertDoctorCards({
250
260
  runId,
251
- cards: normalizedResult.cards,
261
+ cards,
252
262
  });
253
263
  completeDoctorRun({
254
264
  id: runId,
@@ -342,7 +352,11 @@ const createDoctorService = ({
342
352
  throw new Error("Doctor import requires raw output");
343
353
  }
344
354
  const normalizedResult = normalizeDoctorResult(normalizedRawOutput);
345
- captureEvidenceSnippets(normalizedResult.cards, workspaceRoot);
355
+ const bootstrapTruncationCards = buildBootstrapTruncationCards(
356
+ analyzeBootstrapContext({ workspaceRoot }),
357
+ );
358
+ const cards = [...bootstrapTruncationCards, ...normalizedResult.cards];
359
+ captureEvidenceSnippets(cards, workspaceRoot);
346
360
  const workspaceSnapshot = getCurrentWorkspaceSnapshot();
347
361
  const runId = createDoctorRun({
348
362
  status: kDoctorRunStatus.completed,
@@ -354,7 +368,7 @@ const createDoctorService = ({
354
368
  });
355
369
  insertDoctorCards({
356
370
  runId,
357
- cards: normalizedResult.cards,
371
+ cards,
358
372
  });
359
373
  completeDoctorRun({
360
374
  id: runId,
@@ -1,3 +1,4 @@
1
+ const path = require("path");
1
2
  const { spawn, execSync } = require("child_process");
2
3
  const fs = require("fs");
3
4
  const net = require("net");
@@ -5,7 +6,9 @@ const {
5
6
  OPENCLAW_DIR,
6
7
  GATEWAY_HOST,
7
8
  GATEWAY_PORT,
9
+ kControlUiSkillPath,
8
10
  kChannelDefs,
11
+ kOnboardingMarkerPath,
9
12
  kRootDir,
10
13
  } = require("./constants");
11
14
 
@@ -45,7 +48,7 @@ const gatewayEnv = () => ({
45
48
  XDG_CONFIG_HOME: OPENCLAW_DIR,
46
49
  });
47
50
 
48
- const isOnboarded = () => {
51
+ const hasOnboardingModelConfig = () => {
49
52
  const configPath = `${OPENCLAW_DIR}/openclaw.json`;
50
53
  if (!fs.existsSync(configPath)) return false;
51
54
  try {
@@ -59,6 +62,43 @@ const isOnboarded = () => {
59
62
  }
60
63
  };
61
64
 
65
+ const hasLegacyOnboardingArtifacts = () => fs.existsSync(kControlUiSkillPath);
66
+
67
+ const writeOnboardingMarker = (reason) => {
68
+ try {
69
+ fs.mkdirSync(path.dirname(kOnboardingMarkerPath), { recursive: true });
70
+ fs.writeFileSync(
71
+ kOnboardingMarkerPath,
72
+ JSON.stringify(
73
+ {
74
+ onboarded: true,
75
+ reason: String(reason || "unknown"),
76
+ markedAt: new Date().toISOString(),
77
+ },
78
+ null,
79
+ 2,
80
+ ),
81
+ );
82
+ return true;
83
+ } catch (err) {
84
+ console.error(`[alphaclaw] Failed to write onboarding marker: ${err.message}`);
85
+ return false;
86
+ }
87
+ };
88
+
89
+ const isOnboarded = () => {
90
+ if (fs.existsSync(kOnboardingMarkerPath)) return true;
91
+ if (hasOnboardingModelConfig()) {
92
+ writeOnboardingMarker("config_primary_model");
93
+ return true;
94
+ }
95
+ if (hasLegacyOnboardingArtifacts()) {
96
+ writeOnboardingMarker("legacy_artifact_backfill");
97
+ return true;
98
+ }
99
+ return false;
100
+ };
101
+
62
102
  const isGatewayRunning = () =>
63
103
  new Promise((resolve) => {
64
104
  const sock = net.createConnection(GATEWAY_PORT, GATEWAY_HOST);
@@ -70,19 +70,22 @@ const ensureTopicPathForClient = ({
70
70
  const normalizedClient = String(client || "default").trim() || "default";
71
71
  const push = getGmailPushConfig(state);
72
72
  const existingTopic = String(push.topics?.[normalizedClient] || "").trim();
73
- if (existingTopic) {
73
+ const requestedProjectId = String(projectIdOverride || "").trim();
74
+ const existingProjectId = parseProjectIdFromTopicPath(existingTopic);
75
+ if (existingTopic && (!requestedProjectId || requestedProjectId === existingProjectId)) {
74
76
  return { state, topicPath: existingTopic };
75
77
  }
76
78
  const credentials = readGoogleCredentials(normalizedClient);
77
79
  const projectId =
78
- String(projectIdOverride || "").trim() ||
80
+ requestedProjectId ||
79
81
  String(credentials?.projectId || "").trim();
80
82
  if (!projectId) {
81
83
  throw new Error(
82
84
  `Could not detect GCP project_id for client "${normalizedClient}". Save Google credentials first.`,
83
85
  );
84
86
  }
85
- const topicName = createTopicNameForClient(normalizedClient);
87
+ const topicName =
88
+ parseTopicName(existingTopic) || createTopicNameForClient(normalizedClient);
86
89
  const topicPath = `projects/${projectId}/topics/${topicName}`;
87
90
  const updated = setGmailPushConfig({
88
91
  state,
@@ -34,7 +34,7 @@ const createOnboardingService = ({
34
34
  getBaseUrl,
35
35
  startGateway,
36
36
  }) => {
37
- const { OPENCLAW_DIR, WORKSPACE_DIR } = constants;
37
+ const { OPENCLAW_DIR, WORKSPACE_DIR, kOnboardingMarkerPath } = constants;
38
38
 
39
39
  const verifyGithubSetup = async ({
40
40
  githubRepoInput,
@@ -160,6 +160,19 @@ const createOnboardingService = ({
160
160
 
161
161
  installHourlyGitSyncScript({ fs, openclawDir: OPENCLAW_DIR });
162
162
  await installHourlyGitSyncCron({ fs, openclawDir: OPENCLAW_DIR });
163
+ fs.mkdirSync(path.dirname(kOnboardingMarkerPath), { recursive: true });
164
+ fs.writeFileSync(
165
+ kOnboardingMarkerPath,
166
+ JSON.stringify(
167
+ {
168
+ onboarded: true,
169
+ reason: "onboarding_complete",
170
+ markedAt: new Date().toISOString(),
171
+ },
172
+ null,
173
+ 2,
174
+ ),
175
+ );
163
176
 
164
177
  try {
165
178
  await shellCmd(`alphaclaw git-sync -m "initial setup"`, {