@chrysb/alphaclaw 0.8.3-beta.2 → 0.8.3-beta.4

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.
@@ -2,6 +2,7 @@ import { h } from "preact";
2
2
  import {
3
3
  useCallback,
4
4
  useEffect,
5
+ useLayoutEffect,
5
6
  useMemo,
6
7
  useRef,
7
8
  useState,
@@ -17,6 +18,20 @@ const kNewChatEventName = "alphaclaw:chat-new";
17
18
  const kWsReconnectMaxAttempts = 8;
18
19
  const kAutoscrollBottomThresholdPx = 40;
19
20
  const kChatDebugQueryFlag = "chatDebug";
21
+ const kComposerMaxLines = 5;
22
+ const kComposerFontSizePx = 12;
23
+ const kComposerLineHeight = 1.4;
24
+ const kComposerPaddingYPx = 20;
25
+
26
+ const resizeComposerTextarea = (element) => {
27
+ if (!element) return;
28
+ const linePx = kComposerFontSizePx * kComposerLineHeight;
29
+ const minH = linePx + kComposerPaddingYPx;
30
+ const maxH = linePx * kComposerMaxLines + kComposerPaddingYPx;
31
+ element.style.height = "auto";
32
+ const next = Math.min(Math.max(element.scrollHeight, minH), maxH);
33
+ element.style.height = `${next}px`;
34
+ };
20
35
 
21
36
  const buildMessage = ({
22
37
  role = "assistant",
@@ -174,8 +189,10 @@ export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => {
174
189
  const [activeRunBySession, setActiveRunBySession] = useState({});
175
190
  const [connectionError, setConnectionError] = useState("");
176
191
  const [historyLoading, setHistoryLoading] = useState(false);
192
+ const [assistantStreamStarted, setAssistantStreamStarted] = useState(false);
177
193
  const wsRef = useRef(null);
178
194
  const threadRef = useRef(null);
195
+ const composerRef = useRef(null);
179
196
  const reconnectTimerRef = useRef(null);
180
197
  const reconnectAttemptsRef = useRef(0);
181
198
  const selectedSessionKeyRef = useRef(selectedSessionKey);
@@ -206,6 +223,14 @@ export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => {
206
223
  selectedSessionKeyRef.current = selectedSessionKey;
207
224
  }, [selectedSessionKey]);
208
225
 
226
+ useEffect(() => {
227
+ setAssistantStreamStarted(false);
228
+ }, [selectedSessionKey]);
229
+
230
+ useLayoutEffect(() => {
231
+ resizeComposerTextarea(composerRef.current);
232
+ }, [draft, selectedSessionKey]);
233
+
209
234
  useEffect(() => {
210
235
  if (!selectedSessionKey) return;
211
236
  setDraft(String(draftBySession[selectedSessionKey] || ""));
@@ -294,6 +319,7 @@ export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => {
294
319
  setIsConnected(false);
295
320
  setStreaming(false);
296
321
  setSending(false);
322
+ setAssistantStreamStarted(false);
297
323
  setHistoryLoading(false);
298
324
  if (realtimeDisabledRef.current) return;
299
325
  if (reconnectAttemptsRef.current >= kWsReconnectMaxAttempts) return;
@@ -368,6 +394,7 @@ export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => {
368
394
  if (!chunkSessionKey || !messageId) return;
369
395
  setSending(false);
370
396
  setStreaming(true);
397
+ setAssistantStreamStarted(true);
371
398
  setMessagesBySession((currentMap) => {
372
399
  const currentMessages = currentMap[chunkSessionKey] || [];
373
400
  const lastMessage = currentMessages[currentMessages.length - 1];
@@ -424,6 +451,8 @@ export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => {
424
451
  payload.sessionKey || selectedSessionKeyRef.current || "",
425
452
  );
426
453
  if (!toolSessionKey) return;
454
+ setSending(false);
455
+ setAssistantStreamStarted(true);
427
456
  const toolPhase = String(payload.phase || "").toLowerCase();
428
457
  const toolCall =
429
458
  payload?.toolCall && typeof payload.toolCall === "object"
@@ -552,6 +581,7 @@ export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => {
552
581
  );
553
582
  const runId = String(payload.runId || "");
554
583
  if (!nextSessionKey || !runId) return;
584
+ setSending(false);
555
585
  setActiveRunBySession((currentMap) => ({
556
586
  ...currentMap,
557
587
  [nextSessionKey]: runId,
@@ -572,6 +602,7 @@ export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => {
572
602
  }
573
603
  setSending(false);
574
604
  setStreaming(false);
605
+ setAssistantStreamStarted(false);
575
606
  setHistoryLoading(false);
576
607
  if (doneSessionKey && ws && ws.readyState === 1) {
577
608
  setHistoryLoading(true);
@@ -592,6 +623,7 @@ export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => {
592
623
  if (payload.type === "error") {
593
624
  setSending(false);
594
625
  setStreaming(false);
626
+ setAssistantStreamStarted(false);
595
627
  setHistoryLoading(false);
596
628
  const errorSessionKey = String(
597
629
  payload.sessionKey || selectedSessionKeyRef.current || "",
@@ -791,6 +823,7 @@ export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => {
791
823
  ...currentMap,
792
824
  [selectedSessionKey]: "",
793
825
  }));
826
+ setAssistantStreamStarted(false);
794
827
  setSending(true);
795
828
  setMessagesBySession((currentMap) => ({
796
829
  ...currentMap,
@@ -829,8 +862,20 @@ export const ChatRoute = ({ sessions = [], selectedSessionKey = "" }) => {
829
862
  });
830
863
  setStreaming(false);
831
864
  setSending(false);
865
+ setAssistantStreamStarted(false);
832
866
  }, [appendDebugEvent, selectedSessionKey]);
833
867
 
868
+ const handleComposerKeyDown = useCallback(
869
+ (event) => {
870
+ if (event.key !== "Enter") return;
871
+ if (event.shiftKey) return;
872
+ if (event.isComposing) return;
873
+ event.preventDefault();
874
+ handleSend();
875
+ },
876
+ [handleSend],
877
+ );
878
+
834
879
  const rawHistory = selectedSessionKey
835
880
  ? rawHistoryBySession[selectedSessionKey]
836
881
  : null;
@@ -1024,14 +1069,12 @@ ${JSON.stringify(
1024
1069
  })()}
1025
1070
  `,
1026
1071
  )}
1027
- ${selectedSessionKey && (sending || streaming)
1072
+ ${selectedSessionKey &&
1073
+ (sending || streaming) &&
1074
+ !assistantStreamStarted
1028
1075
  ? html`
1029
1076
  <div class="chat-bubble is-assistant chat-typing-indicator">
1030
- <div class="chat-bubble-meta">
1031
- <span>Agent</span>
1032
- <span>${isConnected ? "typing..." : "reconnecting..."}</span>
1033
- </div>
1034
- <div class="chat-typing-dots">
1077
+ <div class="chat-typing-dots" aria-hidden="true">
1035
1078
  <span></span><span></span><span></span>
1036
1079
  </div>
1037
1080
  </div>
@@ -1055,12 +1098,15 @@ ${JSON.stringify(
1055
1098
  <div class="chat-composer">
1056
1099
  <textarea
1057
1100
  class="chat-composer-input"
1101
+ ref=${composerRef}
1102
+ rows=${1}
1058
1103
  placeholder=${selectedSessionKey
1059
- ? "Type a message..."
1104
+ ? "Message… (Enter to send, Shift+Enter for newline)"
1060
1105
  : "Select a session to start"}
1061
1106
  value=${draft}
1062
1107
  disabled=${!selectedSessionKey || sending || !isConnected}
1063
1108
  oninput=${handleDraftInput}
1109
+ onkeydown=${handleComposerKeyDown}
1064
1110
  ></textarea>
1065
1111
  <div class="chat-composer-actions">
1066
1112
  ${streaming
@@ -1085,7 +1131,7 @@ ${JSON.stringify(
1085
1131
  !String(draft || "").trim()}
1086
1132
  onclick=${handleSend}
1087
1133
  >
1088
- ${sending || streaming ? "Sending..." : "Send"}
1134
+ ${sending ? "Sending..." : "Send"}
1089
1135
  </button>
1090
1136
  </div>
1091
1137
  </div>
@@ -354,6 +354,10 @@ const createChatWsService = ({
354
354
  let gatewayConnectPromise = null;
355
355
  const pendingGatewayRequests = new Map();
356
356
  const runTargets = new Map();
357
+ /** While `chat.send` is in flight, agent events can arrive before we have `runId` + runTargets — match by session. */
358
+ const pendingSessionBySessionKey = new Map();
359
+ /** Agent events keyed by runId when the event references a run not yet in runTargets (race with chat.send response). */
360
+ const pendingAgentEventsByRunId = new Map();
357
361
  const browserRuns = new WeakMap();
358
362
 
359
363
  const sendJson = (ws, payload = {}) => {
@@ -382,6 +386,9 @@ const createChatWsService = ({
382
386
  };
383
387
 
384
388
  const clearRunTargetsForBrowser = (ws) => {
389
+ for (const [sk, pending] of pendingSessionBySessionKey.entries()) {
390
+ if (pending.ws === ws) pendingSessionBySessionKey.delete(sk);
391
+ }
385
392
  const runs = browserRuns.get(ws);
386
393
  if (!runs) return;
387
394
  for (const runId of runs) runTargets.delete(runId);
@@ -416,6 +423,7 @@ const createChatWsService = ({
416
423
  const markGatewayDisconnected = (reason = "Gateway disconnected") => {
417
424
  gatewaySocket = null;
418
425
  gatewayConnectPromise = null;
426
+ pendingAgentEventsByRunId.clear();
419
427
  rejectAllGatewayRequests(reason);
420
428
  };
421
429
 
@@ -436,6 +444,17 @@ const createChatWsService = ({
436
444
  sessionTarget = targetRow;
437
445
  }
438
446
  if (sessionTarget) return { runId: "", target: sessionTarget };
447
+ const pending = pendingSessionBySessionKey.get(sessionKey);
448
+ if (pending) {
449
+ return {
450
+ runId: resolveRunIdFromPayload(payload),
451
+ target: {
452
+ ws: pending.ws,
453
+ messageId: pending.messageId,
454
+ sessionKey,
455
+ },
456
+ };
457
+ }
439
458
  }
440
459
  if (runTargets.size === 1) {
441
460
  for (const [singleRunId, singleTarget] of runTargets.entries()) {
@@ -446,9 +465,60 @@ const createChatWsService = ({
446
465
  };
447
466
  if (eventName === "agent") {
448
467
  const { runId, target } = resolveTargetForPayload();
449
- if (!target) return;
468
+ if (!target) {
469
+ const runIdEarly = resolveRunIdFromPayload(payload);
470
+ if (runIdEarly && !runTargets.get(runIdEarly)) {
471
+ const list = pendingAgentEventsByRunId.get(runIdEarly) || [];
472
+ list.push(eventPayload);
473
+ pendingAgentEventsByRunId.set(runIdEarly, list);
474
+ }
475
+ return;
476
+ }
450
477
  const stream = String(payload?.stream || "");
451
478
  const data = payload?.data || {};
479
+ if (stream === "tool") {
480
+ const toolPhase = String(data?.phase || "");
481
+ const toolName = String(data?.name || "unknown");
482
+ const toolCallId = String(data?.toolCallId || "");
483
+ if (toolPhase === "start") {
484
+ sendJson(target.ws, {
485
+ type: "tool",
486
+ phase: "call",
487
+ messageId: target.messageId,
488
+ sessionKey: target.sessionKey,
489
+ timestamp: Number(payload?.ts) || Date.now(),
490
+ toolCall: {
491
+ id: toolCallId,
492
+ name: toolName,
493
+ arguments: data?.args || null,
494
+ partialJson: "",
495
+ },
496
+ toolResult: null,
497
+ rawEvent: eventPayload || null,
498
+ });
499
+ } else if (toolPhase === "result") {
500
+ const resultText = collectTextFromUnknownShape(data?.result);
501
+ sendJson(target.ws, {
502
+ type: "tool",
503
+ phase: "result",
504
+ messageId: target.messageId,
505
+ sessionKey: target.sessionKey,
506
+ timestamp: Number(payload?.ts) || Date.now(),
507
+ toolCall: null,
508
+ toolResult: {
509
+ role: "toolResult",
510
+ toolCallId,
511
+ toolName,
512
+ content: resultText
513
+ ? [{ type: "text", text: resultText }]
514
+ : [],
515
+ isError: data?.isError === true,
516
+ },
517
+ rawEvent: eventPayload || null,
518
+ });
519
+ }
520
+ return;
521
+ }
452
522
  const toolCall =
453
523
  extractToolCallFromUnknownShape(payload) ||
454
524
  extractToolCallFromUnknownShape(data);
@@ -557,7 +627,7 @@ const createChatWsService = ({
557
627
  },
558
628
  role: "operator",
559
629
  scopes: kGatewayChatBridgeScopes,
560
- caps: [],
630
+ caps: ["tool-events"],
561
631
  commands: [],
562
632
  permissions: {},
563
633
  auth: { token: getGatewayToken() },
@@ -689,13 +759,21 @@ const createChatWsService = ({
689
759
  });
690
760
  return;
691
761
  }
692
- const result = await requestGateway("chat.send", {
693
- sessionKey,
694
- message: content,
695
- idempotencyKey: crypto.randomUUID(),
696
- });
762
+ pendingSessionBySessionKey.set(sessionKey, { ws, messageId });
763
+ let result;
764
+ try {
765
+ result = await requestGateway("chat.send", {
766
+ sessionKey,
767
+ message: content,
768
+ idempotencyKey: crypto.randomUUID(),
769
+ });
770
+ } catch (err) {
771
+ pendingSessionBySessionKey.delete(sessionKey);
772
+ throw err;
773
+ }
697
774
  const runId = String(result?.runId || "").trim();
698
775
  if (!runId) {
776
+ pendingSessionBySessionKey.delete(sessionKey);
699
777
  sendJson(ws, {
700
778
  type: "error",
701
779
  message: "Something went wrong connecting to the agent.",
@@ -706,12 +784,20 @@ const createChatWsService = ({
706
784
  }
707
785
  runTargets.set(runId, { ws, messageId, sessionKey });
708
786
  registerRunForBrowser(ws, runId);
787
+ pendingSessionBySessionKey.delete(sessionKey);
709
788
  sendJson(ws, {
710
789
  type: "started",
711
790
  sessionKey,
712
791
  runId,
713
792
  messageId,
714
793
  });
794
+ const buffered = pendingAgentEventsByRunId.get(runId);
795
+ if (buffered && buffered.length) {
796
+ pendingAgentEventsByRunId.delete(runId);
797
+ for (const stored of buffered) {
798
+ handleGatewayEvent(stored);
799
+ }
800
+ }
715
801
  };
716
802
 
717
803
  const handleStop = async ({ ws, payload }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.8.3-beta.2",
3
+ "version": "0.8.3-beta.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -20,12 +20,13 @@
20
20
  "files": [
21
21
  "bin/",
22
22
  "lib/",
23
- "patches/"
23
+ "patches/",
24
+ "scripts/apply-openclaw-patches.js"
24
25
  ],
25
26
  "scripts": {
26
27
  "start": "node bin/alphaclaw.js start",
27
28
  "build:ui": "node scripts/build-ui.mjs",
28
- "postinstall": "patch-package",
29
+ "postinstall": "node ./scripts/apply-openclaw-patches.js",
29
30
  "test": "vitest run",
30
31
  "test:watch": "vitest",
31
32
  "test:watchdog": "vitest run tests/server/watchdog.test.js tests/server/watchdog-db.test.js tests/server/routes-watchdog.test.js",
@@ -0,0 +1,99 @@
1
+ /**
2
+ * patch-package resolves paths relative to the npm/yarn project root (where the
3
+ * lockfile lives). When this package's postinstall runs, process.cwd() is often
4
+ * this package directory, so a plain `patch-package` call treats that as the
5
+ * app root and looks for ./node_modules/openclaw under it — but openclaw is
6
+ * usually hoisted to the consumer's top-level node_modules.
7
+ *
8
+ * This script finds the real install root (directory containing a lockfile) and
9
+ * runs patch-package there with --patch-dir pointing at our bundled patches/.
10
+ */
11
+ const { spawnSync } = require("child_process");
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+
15
+ const kAlphaclawRoot = path.join(__dirname, "..");
16
+
17
+ const findProjectRootFromOpenclawDir = (openclawDir) => {
18
+ let dir = path.resolve(openclawDir);
19
+ for (let i = 0; i < 30; i += 1) {
20
+ if (
21
+ fs.existsSync(path.join(dir, "package-lock.json")) ||
22
+ fs.existsSync(path.join(dir, "yarn.lock")) ||
23
+ fs.existsSync(path.join(dir, "pnpm-lock.yaml"))
24
+ ) {
25
+ return dir;
26
+ }
27
+ const parent = path.dirname(dir);
28
+ if (parent === dir) break;
29
+ dir = parent;
30
+ }
31
+ return path.dirname(path.dirname(openclawDir));
32
+ };
33
+
34
+ const main = () => {
35
+ const patchesDir = path.join(kAlphaclawRoot, "patches");
36
+ if (!fs.existsSync(patchesDir)) {
37
+ return;
38
+ }
39
+ const hasPatch = fs
40
+ .readdirSync(patchesDir)
41
+ .some((name) => name.endsWith(".patch"));
42
+ if (!hasPatch) {
43
+ return;
44
+ }
45
+
46
+ let openclawMainPath;
47
+ try {
48
+ openclawMainPath = require.resolve("openclaw", { paths: [kAlphaclawRoot] });
49
+ } catch {
50
+ return;
51
+ }
52
+
53
+ const openclawDir = (() => {
54
+ let dir = path.dirname(openclawMainPath);
55
+ for (let i = 0; i < 8; i += 1) {
56
+ const pkgPath = path.join(dir, "package.json");
57
+ if (fs.existsSync(pkgPath)) {
58
+ try {
59
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
60
+ if (pkg.name === "openclaw") return dir;
61
+ } catch {
62
+ /* continue */
63
+ }
64
+ }
65
+ const parent = path.dirname(dir);
66
+ if (parent === dir) break;
67
+ dir = parent;
68
+ }
69
+ return path.dirname(path.dirname(openclawMainPath));
70
+ })();
71
+ const projectRoot = findProjectRootFromOpenclawDir(openclawDir);
72
+
73
+ let relPatchDir = path.relative(projectRoot, patchesDir);
74
+ if (relPatchDir.startsWith("..") || path.isAbsolute(relPatchDir)) {
75
+ console.error(
76
+ "[@chrysb/alphaclaw] patch-package: could not resolve patch dir relative to project root",
77
+ );
78
+ process.exit(1);
79
+ }
80
+ relPatchDir = relPatchDir.split(path.sep).join("/");
81
+
82
+ const patchPackageMain = require.resolve("patch-package/dist/index.js", {
83
+ paths: [kAlphaclawRoot],
84
+ });
85
+
86
+ const result = spawnSync(
87
+ process.execPath,
88
+ [patchPackageMain, "--patch-dir", relPatchDir],
89
+ { cwd: projectRoot, stdio: "inherit", env: process.env },
90
+ );
91
+ if (result.error) {
92
+ throw result.error;
93
+ }
94
+ if (result.status !== 0 && result.status !== null) {
95
+ process.exit(result.status);
96
+ }
97
+ };
98
+
99
+ main();