quadwork 1.5.1 → 1.5.3

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.
Files changed (80) hide show
  1. package/bin/quadwork.js +13 -1
  2. package/out/404.html +1 -1
  3. package/out/__next.__PAGE__.txt +1 -1
  4. package/out/__next._full.txt +1 -1
  5. package/out/__next._head.txt +1 -1
  6. package/out/__next._index.txt +1 -1
  7. package/out/__next._tree.txt +1 -1
  8. package/out/_next/static/chunks/{0-7v31f-nsgw-.js → 09sq17vme9g6p.js} +1 -1
  9. package/out/_next/static/chunks/{0m83k84.midd1.js → 0wreuebrwlg.2.js} +1 -1
  10. package/out/_not-found/__next._full.txt +1 -1
  11. package/out/_not-found/__next._head.txt +1 -1
  12. package/out/_not-found/__next._index.txt +1 -1
  13. package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
  14. package/out/_not-found/__next._not-found.txt +1 -1
  15. package/out/_not-found/__next._tree.txt +1 -1
  16. package/out/_not-found.html +1 -1
  17. package/out/_not-found.txt +1 -1
  18. package/out/app-shell/__next._full.txt +1 -1
  19. package/out/app-shell/__next._head.txt +1 -1
  20. package/out/app-shell/__next._index.txt +1 -1
  21. package/out/app-shell/__next._tree.txt +1 -1
  22. package/out/app-shell/__next.app-shell.__PAGE__.txt +1 -1
  23. package/out/app-shell/__next.app-shell.txt +1 -1
  24. package/out/app-shell.html +1 -1
  25. package/out/app-shell.txt +1 -1
  26. package/out/index.html +1 -1
  27. package/out/index.txt +1 -1
  28. package/out/project/_/__next._full.txt +2 -2
  29. package/out/project/_/__next._head.txt +1 -1
  30. package/out/project/_/__next._index.txt +1 -1
  31. package/out/project/_/__next._tree.txt +1 -1
  32. package/out/project/_/__next.project.$d$id.__PAGE__.txt +2 -2
  33. package/out/project/_/__next.project.$d$id.txt +1 -1
  34. package/out/project/_/__next.project.txt +1 -1
  35. package/out/project/_/memory/__next._full.txt +1 -1
  36. package/out/project/_/memory/__next._head.txt +1 -1
  37. package/out/project/_/memory/__next._index.txt +1 -1
  38. package/out/project/_/memory/__next._tree.txt +1 -1
  39. package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +1 -1
  40. package/out/project/_/memory/__next.project.$d$id.memory.txt +1 -1
  41. package/out/project/_/memory/__next.project.$d$id.txt +1 -1
  42. package/out/project/_/memory/__next.project.txt +1 -1
  43. package/out/project/_/memory.html +1 -1
  44. package/out/project/_/memory.txt +1 -1
  45. package/out/project/_/queue/__next._full.txt +1 -1
  46. package/out/project/_/queue/__next._head.txt +1 -1
  47. package/out/project/_/queue/__next._index.txt +1 -1
  48. package/out/project/_/queue/__next._tree.txt +1 -1
  49. package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +1 -1
  50. package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
  51. package/out/project/_/queue/__next.project.$d$id.txt +1 -1
  52. package/out/project/_/queue/__next.project.txt +1 -1
  53. package/out/project/_/queue.html +1 -1
  54. package/out/project/_/queue.txt +1 -1
  55. package/out/project/_.html +1 -1
  56. package/out/project/_.txt +2 -2
  57. package/out/settings/__next._full.txt +1 -1
  58. package/out/settings/__next._head.txt +1 -1
  59. package/out/settings/__next._index.txt +1 -1
  60. package/out/settings/__next._tree.txt +1 -1
  61. package/out/settings/__next.settings.__PAGE__.txt +1 -1
  62. package/out/settings/__next.settings.txt +1 -1
  63. package/out/settings.html +1 -1
  64. package/out/settings.txt +1 -1
  65. package/out/setup/__next._full.txt +1 -1
  66. package/out/setup/__next._head.txt +1 -1
  67. package/out/setup/__next._index.txt +1 -1
  68. package/out/setup/__next._tree.txt +1 -1
  69. package/out/setup/__next.setup.__PAGE__.txt +1 -1
  70. package/out/setup/__next.setup.txt +1 -1
  71. package/out/setup.html +1 -1
  72. package/out/setup.txt +1 -1
  73. package/package.json +1 -1
  74. package/server/routes.chatWsSend.test.js +161 -0
  75. package/server/routes.js +250 -32
  76. package/server/routes.telegramBridge.test.js +145 -2
  77. package/templates/config.toml +8 -0
  78. /package/out/_next/static/{wxXtT0v8ALxniu3OdJwt5 → X4zdS6Y6HkLOaElNeHwnq}/_buildManifest.js +0 -0
  79. /package/out/_next/static/{wxXtT0v8ALxniu3OdJwt5 → X4zdS6Y6HkLOaElNeHwnq}/_clientMiddlewareManifest.js +0 -0
  80. /package/out/_next/static/{wxXtT0v8ALxniu3OdJwt5 → X4zdS6Y6HkLOaElNeHwnq}/_ssgManifest.js +0 -0
package/out/setup.txt CHANGED
@@ -11,7 +11,7 @@ d:I[11717,["/_next/static/chunks/04_t39bv8y9pe.js","/_next/static/chunks/0ox7p_s
11
11
  f:I[92243,["/_next/static/chunks/04_t39bv8y9pe.js","/_next/static/chunks/0ox7p_szjhn69.js"],"default",1]
12
12
  :HL["/_next/static/chunks/0ccoe1hsu70ql.css","style"]
13
13
  :HL["/_next/static/media/797e433ab948586e-s.p.0.q-h669a_dqa.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
14
- 0:{"P":null,"c":["","setup"],"q":"","i":false,"f":[[["",{"children":["setup",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0ccoe1hsu70ql.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/04_t39bv8y9pe.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/0ox7p_szjhn69.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"geist_mono_8d43a2aa-module__8Li5zG__variable h-full","children":["$","body",null,{"className":"h-full flex flex-col","children":[["$","$L2",null,{}],["$","div",null,{"className":"flex flex-1 min-h-0","children":[["$","$L3",null,{}],["$","main",null,{"className":"flex-1 min-w-0 overflow-auto","children":["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]]}]]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[["$","$L6",null,{}],[["$","script","script-0",{"src":"/_next/static/chunks/084lff9v4p_vh.js","async":true,"nonce":"$undefined"}]],["$","$L7",null,{"children":["$","$8",null,{"name":"Next.MetadataOutlet","children":"$@9"}]}]]}],{},null,false,null]},null,false,"$@a"]},null,false,null],["$","$1","h",{"children":[null,["$","$Lb",null,{"children":"$Lc"}],["$","div",null,{"hidden":true,"children":["$","$Ld",null,{"children":["$","$8",null,{"name":"Next.Metadata","children":"$Le"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],false]],"m":"$undefined","G":["$f",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0ccoe1hsu70ql.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"wxXtT0v8ALxniu3OdJwt5"}
14
+ 0:{"P":null,"c":["","setup"],"q":"","i":false,"f":[[["",{"children":["setup",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",16],[["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0ccoe1hsu70ql.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","script","script-0",{"src":"/_next/static/chunks/04_t39bv8y9pe.js","async":true,"nonce":"$undefined"}],["$","script","script-1",{"src":"/_next/static/chunks/0ox7p_szjhn69.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"geist_mono_8d43a2aa-module__8Li5zG__variable h-full","children":["$","body",null,{"className":"h-full flex flex-col","children":[["$","$L2",null,{}],["$","div",null,{"className":"flex flex-1 min-h-0","children":[["$","$L3",null,{}],["$","main",null,{"className":"flex-1 min-w-0 overflow-auto","children":["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]]}]]}]}]]}],{"children":[["$","$1","c",{"children":[null,["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],{"children":[["$","$1","c",{"children":[["$","$L6",null,{}],[["$","script","script-0",{"src":"/_next/static/chunks/084lff9v4p_vh.js","async":true,"nonce":"$undefined"}]],["$","$L7",null,{"children":["$","$8",null,{"name":"Next.MetadataOutlet","children":"$@9"}]}]]}],{},null,false,null]},null,false,"$@a"]},null,false,null],["$","$1","h",{"children":[null,["$","$Lb",null,{"children":"$Lc"}],["$","div",null,{"hidden":true,"children":["$","$Ld",null,{"children":["$","$8",null,{"name":"Next.Metadata","children":"$Le"}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],false]],"m":"$undefined","G":["$f",[["$","link","0",{"rel":"stylesheet","href":"/_next/static/chunks/0ccoe1hsu70ql.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]]],"S":true,"h":null,"s":"$undefined","l":"$undefined","p":"$undefined","d":"$undefined","b":"X4zdS6Y6HkLOaElNeHwnq"}
15
15
  10:[]
16
16
  a:"$W10"
17
17
  c:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quadwork",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "description": "Unified dashboard for multi-agent coding teams — 4 AI agents, one terminal",
5
5
  "bin": {
6
6
  "quadwork": "./bin/quadwork.js"
@@ -0,0 +1,161 @@
1
+ // #236 / quadwork#236: sendViaWebSocket ack/body/error regression.
2
+ // Stands up a minimal fake AgentChattr ws server that mirrors app.py's
3
+ // `type:"message"` handler behavior (accept the frame, assign an id +
4
+ // timestamp, broadcast `{type:"message", data: msg}` back to the
5
+ // sender) and verifies:
6
+ //
7
+ // 1. Successful send resolves with {ok:true, message:{id,…}} — the
8
+ // echoed broadcast frame, not a fake {ok:true}.
9
+ // 2. Attachments survive the round trip.
10
+ // 3. History replay on connect does NOT satisfy the ack (the echo
11
+ // must come AFTER the send, not before).
12
+ // 4. A premature close without an echo rejects with an error (the
13
+ // old fire-and-forget path silently resolved).
14
+ // 5. Close code 4003 rejects with err.code === "EAGENTCHATTR_401"
15
+ // so the /api/chat handler can surface a proper 401.
16
+ //
17
+ // Run with: node server/routes.chatWsSend.test.js
18
+
19
+ const assert = require("node:assert/strict");
20
+ const http = require("node:http");
21
+ const { WebSocketServer } = require("ws");
22
+ const { sendViaWebSocket } = require("./routes");
23
+
24
+ function startFakeAc({ historyBeforeAck = [], rejectWithCode = null, dropBeforeAck = false } = {}) {
25
+ const server = http.createServer();
26
+ const wss = new WebSocketServer({ noServer: true });
27
+ server.on("upgrade", (req, socket, head) => {
28
+ if (rejectWithCode === 4003) {
29
+ // Accept the upgrade, then immediately close with 4003 the way
30
+ // AC's /ws handler does on invalid token.
31
+ wss.handleUpgrade(req, socket, head, (ws) => {
32
+ ws.close(4003, "forbidden: invalid session token");
33
+ });
34
+ return;
35
+ }
36
+ wss.handleUpgrade(req, socket, head, (ws) => {
37
+ // Simulate history replay on connect — these must NOT satisfy
38
+ // the ack, even if they match (sender,text,channel).
39
+ let historyMaxId = 0;
40
+ for (const msg of historyBeforeAck) {
41
+ ws.send(JSON.stringify({ type: "message", data: msg }));
42
+ if (typeof msg.id === "number" && msg.id > historyMaxId) historyMaxId = msg.id;
43
+ }
44
+ // Mirror AC: emit one `type:"status"` frame after history so the
45
+ // client knows the replay is done. See agentchattr/app.py
46
+ // `broadcast_status()` call in the /ws handler (line ~1082).
47
+ ws.send(JSON.stringify({ type: "status", data: { ready: true } }));
48
+ let nextId = Math.max(9000, historyMaxId + 1);
49
+ ws.on("message", (raw) => {
50
+ const frame = JSON.parse(raw.toString());
51
+ if (frame.type !== "message") return;
52
+ if (dropBeforeAck) {
53
+ ws.close();
54
+ return;
55
+ }
56
+ // Mirror store.add: assign id + timestamp, rebroadcast.
57
+ const echoed = {
58
+ id: nextId++,
59
+ sender: frame.sender,
60
+ text: frame.text,
61
+ channel: frame.channel || "general",
62
+ attachments: frame.attachments || [],
63
+ reply_to: frame.reply_to ?? null,
64
+ timestamp: Date.now() / 1000,
65
+ };
66
+ ws.send(JSON.stringify({ type: "message", data: echoed }));
67
+ });
68
+ });
69
+ });
70
+ return new Promise((resolve) => {
71
+ server.listen(0, "127.0.0.1", () => {
72
+ const { port } = server.address();
73
+ resolve({ url: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(() => r())) });
74
+ });
75
+ });
76
+ }
77
+
78
+ (async () => {
79
+ // 1 + 2) Success path: echo carries server id + attachments preserved.
80
+ {
81
+ const ac = await startFakeAc();
82
+ const result = await sendViaWebSocket(ac.url, "fake-token", {
83
+ text: "hello from test",
84
+ sender: "user",
85
+ channel: "general",
86
+ attachments: [{ url: "/uploads/x.png", name: "x.png" }],
87
+ });
88
+ assert.equal(result.ok, true);
89
+ assert.ok(result.message, "expected echoed message object");
90
+ assert.equal(typeof result.message.id, "number");
91
+ assert.ok(result.message.id >= 9000);
92
+ assert.equal(result.message.text, "hello from test");
93
+ assert.equal(result.message.sender, "user");
94
+ assert.equal(result.message.attachments.length, 1);
95
+ assert.equal(result.message.attachments[0].name, "x.png");
96
+ await ac.close();
97
+ }
98
+
99
+ // 3) Stale history replay must NOT satisfy the ack — even when the
100
+ // history contains a message that is IDENTICAL (sender, text,
101
+ // channel, reply_to) and has a recent timestamp (e.g. a retry of
102
+ // the same message sent <1s ago). Prior heuristic matcher was
103
+ // vulnerable to this; the fix uses the (1) status-frame history
104
+ // boundary and (2) strictly-greater-id correlation baseline so
105
+ // the historical echo is definitionally rejected. Reviewer1
106
+ // flagged this race on PR #382 round 1.
107
+ {
108
+ const stale = {
109
+ id: 42, sender: "user", text: "same words",
110
+ channel: "general", attachments: [], reply_to: null,
111
+ timestamp: Date.now() / 1000, // RIGHT NOW — old heuristic would accept this
112
+ };
113
+ const ac = await startFakeAc({ historyBeforeAck: [stale] });
114
+ const result = await sendViaWebSocket(ac.url, "fake-token", {
115
+ text: "same words",
116
+ sender: "user",
117
+ channel: "general",
118
+ attachments: [],
119
+ });
120
+ assert.ok(result.message.id > 42, `expected live echo id > 42, got ${result.message.id}`);
121
+ await ac.close();
122
+ }
123
+
124
+ // 4) Premature close without ack rejects.
125
+ {
126
+ const ac = await startFakeAc({ dropBeforeAck: true });
127
+ let threw = false;
128
+ try {
129
+ await sendViaWebSocket(ac.url, "fake-token", {
130
+ text: "will drop", sender: "user", channel: "general", attachments: [],
131
+ });
132
+ } catch (err) {
133
+ threw = true;
134
+ assert.match(err.message, /closed before ack|websocket/);
135
+ assert.notEqual(err.code, "EAGENTCHATTR_401");
136
+ }
137
+ assert.equal(threw, true, "expected sendViaWebSocket to reject on premature close");
138
+ await ac.close();
139
+ }
140
+
141
+ // 5) 4003 (bad token) rejects with EAGENTCHATTR_401.
142
+ {
143
+ const ac = await startFakeAc({ rejectWithCode: 4003 });
144
+ let threw = false;
145
+ try {
146
+ await sendViaWebSocket(ac.url, "bad-token", {
147
+ text: "denied", sender: "user", channel: "general", attachments: [],
148
+ });
149
+ } catch (err) {
150
+ threw = true;
151
+ assert.equal(err.code, "EAGENTCHATTR_401");
152
+ }
153
+ assert.equal(threw, true, "expected sendViaWebSocket to reject with EAGENTCHATTR_401");
154
+ await ac.close();
155
+ }
156
+
157
+ console.log("routes.chatWsSend.test.js: all assertions passed (5 cases)");
158
+ })().catch((err) => {
159
+ console.error(err);
160
+ process.exit(1);
161
+ });
package/server/routes.js CHANGED
@@ -148,11 +148,41 @@ router.get("/api/chat", async (req, res) => {
148
148
  const { WebSocket: NodeWebSocket } = require("ws");
149
149
  const { syncChattrToken } = require("./config");
150
150
 
151
+ // #236: wait for AgentChattr to echo our message back over the same ws
152
+ // connection before resolving, instead of fire-and-forgetting. AC's
153
+ // /ws handler does this on every connect:
154
+ // 1. Replays history as N `{type:"message", data: msg}` frames.
155
+ // 2. Sends one `{type:"status", data: …}` frame (broadcast_status).
156
+ // 3. Enters the receive loop and accepts our outgoing frame.
157
+ // After our `type:"message"` is processed, AC calls `store.add()`
158
+ // which broadcasts the stored record back to all clients (including
159
+ // us) as another `{type:"message", data: msg}`.
160
+ //
161
+ // To get a race-free ack we therefore:
162
+ // A. Wait for the first `type:"status"` frame to confirm the
163
+ // history replay is done — any `type:"message"` frame seen
164
+ // BEFORE that is historical and must be ignored.
165
+ // B. Only then send our message and record the highest message
166
+ // id observed so far as a correlation baseline.
167
+ // C. Accept the first post-send `type:"message"` whose payload
168
+ // matches (sender, text, channel, reply_to) AND whose id is
169
+ // strictly greater than the baseline (AC ids are monotonically
170
+ // increasing from store.add). This eliminates the risk a
171
+ // reviewer flagged on #382 round 1: a historical identical
172
+ // message from <1.5s ago could have satisfied the old
173
+ // heuristic matcher.
174
+ // On timeout / early close / 4003, we surface a proper error so the
175
+ // /api/chat handler can return a 5xx (or 401) instead of a silent
176
+ // {ok:true}.
151
177
  function sendViaWebSocket(baseUrl, sessionToken, message) {
152
178
  return new Promise((resolve, reject) => {
153
179
  const wsUrl = `${baseUrl.replace(/^http/, "ws")}/ws?token=${encodeURIComponent(sessionToken || "")}`;
154
180
  const ws = new NodeWebSocket(wsUrl);
155
181
  let settled = false;
182
+ let historyFlushed = false;
183
+ let sent = false;
184
+ let maxIdAtSend = -Infinity;
185
+ let maxHistoryId = -Infinity;
156
186
  const finish = (err, value) => {
157
187
  if (settled) return;
158
188
  settled = true;
@@ -160,14 +190,56 @@ function sendViaWebSocket(baseUrl, sessionToken, message) {
160
190
  if (err) reject(err); else resolve(value);
161
191
  };
162
192
  const giveUp = setTimeout(() => finish(new Error("websocket send timeout")), 4000);
163
- ws.on("open", () => {
193
+ const doSend = () => {
194
+ if (sent || settled) return;
164
195
  try {
196
+ maxIdAtSend = maxHistoryId;
165
197
  ws.send(JSON.stringify({ type: "message", ...message }));
166
- // Server acks via broadcast, but the dashboard's POST /api/chat
167
- // contract only needs to know the message was accepted. Wait
168
- // ~250ms for the server to enqueue + close cleanly.
169
- setTimeout(() => { clearTimeout(giveUp); finish(null, { ok: true }); }, 250);
198
+ sent = true;
170
199
  } catch (err) { clearTimeout(giveUp); finish(err); }
200
+ };
201
+ ws.on("open", () => {
202
+ // Do NOT send yet. Wait for the status frame that marks the
203
+ // end of history replay so we have a clean correlation
204
+ // baseline. A safety timer covers the (unlikely) case of an
205
+ // AC build that doesn't emit status on connect — after 750ms
206
+ // we fall back to sending anyway, using whatever max id we
207
+ // collected from history so far as the baseline.
208
+ setTimeout(() => {
209
+ if (!historyFlushed) {
210
+ historyFlushed = true;
211
+ doSend();
212
+ }
213
+ }, 750);
214
+ });
215
+ ws.on("message", (raw) => {
216
+ if (settled) return;
217
+ let frame;
218
+ try { frame = JSON.parse(raw.toString()); } catch { return; }
219
+ if (!frame || !frame.type) return;
220
+ if (frame.type === "status" && !historyFlushed) {
221
+ historyFlushed = true;
222
+ doSend();
223
+ return;
224
+ }
225
+ if (frame.type !== "message" || !frame.data) return;
226
+ const d = frame.data;
227
+ // Track the highest message id we have observed, whether from
228
+ // history replay or from other live broadcasts. Used as the
229
+ // baseline for the post-send correlation check.
230
+ if (typeof d.id === "number" && d.id > maxHistoryId) {
231
+ maxHistoryId = d.id;
232
+ }
233
+ if (!sent) return; // anything before our send is history
234
+ if (typeof d.id !== "number" || d.id <= maxIdAtSend) return;
235
+ if (d.sender !== message.sender) return;
236
+ if (d.text !== message.text) return;
237
+ if ((d.channel || "general") !== (message.channel || "general")) return;
238
+ const wantReply = message.reply_to ?? null;
239
+ const gotReply = d.reply_to ?? null;
240
+ if (wantReply !== gotReply) return;
241
+ clearTimeout(giveUp);
242
+ finish(null, { ok: true, message: d });
171
243
  });
172
244
  ws.on("error", (err) => { clearTimeout(giveUp); finish(err); });
173
245
  ws.on("close", (code, reason) => {
@@ -179,6 +251,15 @@ function sendViaWebSocket(baseUrl, sessionToken, message) {
179
251
  const e = new Error(msg);
180
252
  e.code = "EAGENTCHATTR_401";
181
253
  finish(e);
254
+ return;
255
+ }
256
+ // Any other premature close after we sent but before we saw
257
+ // the echo is an error — the old code path would have claimed
258
+ // success, silently swallowing a server-side reject.
259
+ if (!settled) {
260
+ clearTimeout(giveUp);
261
+ const r = (reason && reason.toString()) || "";
262
+ finish(new Error(`websocket closed before ack (code=${code}${r ? ", reason=" + r : ""})`));
182
263
  }
183
264
  });
184
265
  });
@@ -861,8 +942,12 @@ router.post("/api/chat", async (req, res) => {
861
942
  const attemptSend = () => sendViaWebSocket(base, sessionToken, message);
862
943
 
863
944
  try {
864
- await attemptSend();
865
- return res.json({ ok: true });
945
+ // #236: sendViaWebSocket now waits for AC's broadcast echo and
946
+ // returns `{ok, message}` where `message` is the stored record
947
+ // (with server-assigned id/timestamp). Pass it through so
948
+ // callers regain parity with the old /api/send response body.
949
+ const result = await attemptSend();
950
+ return res.json({ ok: true, message: result.message });
866
951
  } catch (err) {
867
952
  // If the cached session_token is stale (AgentChattr regenerates
868
953
  // one on every restart) the ws closes with code 4003 — re-sync
@@ -876,8 +961,8 @@ router.post("/api/chat", async (req, res) => {
876
961
  const { token: refreshed } = getChattrConfig(projectId);
877
962
  if (refreshed && refreshed !== sessionToken) {
878
963
  try {
879
- await sendViaWebSocket(base, refreshed, message);
880
- return res.json({ ok: true, resynced: true });
964
+ const retry = await sendViaWebSocket(base, refreshed, message);
965
+ return res.json({ ok: true, resynced: true, message: retry.message });
881
966
  } catch (retryErr) {
882
967
  console.warn(`[chat] retry after token resync failed: ${retryErr.message}`);
883
968
  return res.status(401).json({ error: "AgentChattr auth failed (token resync did not help)", detail: retryErr.message });
@@ -2300,6 +2385,63 @@ function telegramConfigToml(projectId) {
2300
2385
  return path.join(CONFIG_DIR, `telegram-${projectId}.toml`);
2301
2386
  }
2302
2387
 
2388
+ // #383: path to a project's AgentChattr config.toml. The install
2389
+ // handler patches this file to declare the `telegram-bridge` agent
2390
+ // so AC's registry accepts the bridge's register call.
2391
+ function projectAgentchattrConfigPath(projectId) {
2392
+ return path.join(CONFIG_DIR, projectId, "agentchattr", "config.toml");
2393
+ }
2394
+
2395
+ // #383 Bug 1: prefer the per-project agentchattr_url. Every project
2396
+ // after the first uses a distinct port (8301, 8302, ...), so reading
2397
+ // the global default silently routed bridge traffic to the wrong AC
2398
+ // instance.
2399
+ function resolveProjectAgentchattrUrl(cfg, project) {
2400
+ return (
2401
+ (project && project.agentchattr_url) ||
2402
+ (cfg && cfg.agentchattr_url) ||
2403
+ "http://127.0.0.1:8300"
2404
+ );
2405
+ }
2406
+
2407
+ // #383 Bug 2: the upstream bridge only reads `agentchattr_url` from
2408
+ // inside `[telegram]`. A separate `[agentchattr]` section is silently
2409
+ // ignored and the bridge falls back to its hardcoded :8300 default.
2410
+ function buildTelegramBridgeToml(tg) {
2411
+ return (
2412
+ `[telegram]\n` +
2413
+ `bot_token = "${tg.bot_token}"\n` +
2414
+ `chat_id = "${tg.chat_id}"\n` +
2415
+ `agentchattr_url = "${tg.agentchattr_url}"\n`
2416
+ );
2417
+ }
2418
+
2419
+ // #383 Bug 3: AC's registry rejects any base name not pre-declared
2420
+ // in config.toml with `400 unknown base`. The bridge registers as
2421
+ // `telegram-bridge`, so every per-project AC config must declare it.
2422
+ // Idempotent: only appends if the section is not already present.
2423
+ function patchAgentchattrConfigForTelegramBridge(tomlText) {
2424
+ if (/^\[agents\.telegram-bridge\]\s*$/m.test(tomlText)) {
2425
+ return { text: tomlText, changed: false };
2426
+ }
2427
+ const sep = tomlText.length === 0 || tomlText.endsWith("\n") ? "" : "\n";
2428
+ const block = `\n[agents.telegram-bridge]\nlabel = "Telegram Bridge"\n`;
2429
+ return { text: tomlText + sep + block, changed: true };
2430
+ }
2431
+
2432
+ // #383 Bug 4: the upstream bridge treats env vars as higher
2433
+ // precedence than TOML values. If the parent shell exported
2434
+ // TELEGRAM_BOT_TOKEN for a different bot, the bridge silently ran
2435
+ // as the wrong identity. Scrub those keys from the child's env so
2436
+ // the TOML is the single source of truth.
2437
+ function buildTelegramBridgeSpawnEnv(parentEnv) {
2438
+ const env = { ...parentEnv };
2439
+ delete env.TELEGRAM_BOT_TOKEN;
2440
+ delete env.TELEGRAM_CHAT_ID;
2441
+ delete env.AGENTCHATTR_URL;
2442
+ return env;
2443
+ }
2444
+
2303
2445
  // #353: per-project log file for the bridge subprocess. The start
2304
2446
  // handler redirects stdout + stderr here so crashes (ImportError,
2305
2447
  // config parse, auth failure) are recoverable instead of
@@ -2339,7 +2481,12 @@ function readLastLines(filePath, n) {
2339
2481
  // otherwise. Keep the import list small and close to what the
2340
2482
  // bridge actually needs; add modules here if the bridge gains new
2341
2483
  // hard deps.
2342
- function checkTelegramBridgePythonDeps() {
2484
+ // #380: `pythonPath` defaults to bare `python3` for backward-compat,
2485
+ // but the production call sites (install, start) MUST pass the
2486
+ // dedicated bridge venv's interpreter (`<BRIDGE_DIR>/.venv/bin/python3`)
2487
+ // so the import check runs against the same interpreter the spawn will
2488
+ // use. See #379 research ticket for root cause.
2489
+ function checkTelegramBridgePythonDeps(pythonPath = "python3") {
2343
2490
  try {
2344
2491
  // Only check the third-party module the bridge actually needs
2345
2492
  // at import time — `requests`. Toml parsing differs between
@@ -2347,7 +2494,7 @@ function checkTelegramBridgePythonDeps() {
2347
2494
  // genuine toml import failure will now be captured in the
2348
2495
  // bridge log file on spawn, so this pre-flight stays narrow
2349
2496
  // and avoids false negatives on older Python installs.
2350
- execFileSync("python3", ["-c", "import requests"], {
2497
+ execFileSync(pythonPath, ["-c", "import requests"], {
2351
2498
  encoding: "utf-8",
2352
2499
  timeout: 10000,
2353
2500
  stdio: ["ignore", "pipe", "pipe"],
@@ -2412,7 +2559,8 @@ function getProjectTelegram(projectId) {
2412
2559
  return {
2413
2560
  bot_token: resolveToken(project.telegram.bot_token || ""),
2414
2561
  chat_id: project.telegram.chat_id || "",
2415
- agentchattr_url: cfg.agentchattr_url || "http://127.0.0.1:8300",
2562
+ // #383 Bug 1: prefer per-project URL over the global default.
2563
+ agentchattr_url: resolveProjectAgentchattrUrl(cfg, project),
2416
2564
  };
2417
2565
  } catch {
2418
2566
  return null;
@@ -2503,39 +2651,76 @@ router.post("/api/telegram", async (req, res) => {
2503
2651
  }
2504
2652
  }
2505
2653
  case "install": {
2506
- // #353: pip3 can exit 0 on some systems (PEP 668 externally-
2507
- // managed environments, non-writable site-packages) even when
2508
- // the subsequent import still fails. After the pip step, run
2509
- // a post-install import check and surface both the pip output
2510
- // and the import error together if the check fails that's
2511
- // the signal the operator needs to know whether to pick a
2512
- // virtualenv, use --user, or --break-system-packages.
2654
+ // #380: create a dedicated bridge venv at
2655
+ // `<BRIDGE_DIR>/.venv` and install requirements into it using
2656
+ // that venv's pip. All bridge subprocesses then spawn with
2657
+ // `<BRIDGE_DIR>/.venv/bin/python3` by absolute path. See #379
2658
+ // research ticket for the root cause bare `python3` / `pip3`
2659
+ // resolve to Homebrew Python on modern macOS where `requests`
2660
+ // is not available, producing a ModuleNotFoundError on Start.
2661
+ // Idempotent: existing installs missing a `.venv` get the venv
2662
+ // created on top of the existing clone without re-cloning.
2663
+ const venvDir = path.join(BRIDGE_DIR, ".venv");
2664
+ const venvPython = path.join(venvDir, "bin", "python3");
2665
+ const venvPip = path.join(venvDir, "bin", "pip");
2513
2666
  let pipOutput = "";
2514
2667
  try {
2515
2668
  if (!fs.existsSync(BRIDGE_DIR)) {
2516
2669
  execFileSync("gh", ["repo", "clone", "realproject7/agentchattr-telegram", BRIDGE_DIR], { encoding: "utf-8", timeout: 30000 });
2517
2670
  }
2671
+ // #380: create the dedicated venv if missing. `python3 -m venv`
2672
+ // builds a fresh isolated environment that bypasses PEP 668
2673
+ // externally-managed markers, so this works even on Homebrew
2674
+ // Python where bare `pip3 install` would be blocked.
2675
+ if (!fs.existsSync(venvPython)) {
2676
+ execFileSync("python3", ["-m", "venv", venvDir], { encoding: "utf-8", timeout: 60000 });
2677
+ }
2518
2678
  pipOutput = execFileSync(
2519
- "pip3",
2679
+ venvPip,
2520
2680
  ["install", "-r", path.join(BRIDGE_DIR, "requirements.txt")],
2521
- { encoding: "utf-8", timeout: 60000 },
2681
+ { encoding: "utf-8", timeout: 120000 },
2522
2682
  );
2523
2683
  } catch (err) {
2524
- return res.json({ ok: false, error: err.message || "Install failed" });
2684
+ const stderr = (err && err.stderr && err.stderr.toString && err.stderr.toString()) || "";
2685
+ return res.json({ ok: false, error: (stderr.trim() || err.message || "Install failed") });
2525
2686
  }
2526
- const depCheck = checkTelegramBridgePythonDeps();
2687
+ const depCheck = checkTelegramBridgePythonDeps(venvPython);
2527
2688
  if (!depCheck.ok) {
2528
2689
  return res.json({
2529
2690
  ok: false,
2530
2691
  error:
2531
- "pip3 reported success but the bridge's Python deps still fail to import. " +
2532
- "This usually means pip installed into a location python3 cannot see " +
2533
- "(externally-managed environment / PEP 668 / mismatched interpreter).\n\n" +
2692
+ "pip reported success but the bridge venv's Python deps still fail to import. " +
2693
+ "This is unexpected for a freshly-created venv check disk space and permissions " +
2694
+ `on ${venvDir}.\n\n` +
2534
2695
  `Import error: ${depCheck.error}\n\n` +
2535
2696
  `pip output tail:\n${pipOutput.split("\n").slice(-10).join("\n")}`,
2536
2697
  });
2537
2698
  }
2538
- return res.json({ ok: true });
2699
+ // #383 Bug 3: ensure every known project's AC config declares
2700
+ // the `telegram-bridge` agent. Without this, AC's registry
2701
+ // rejects the bridge's register call with `400 unknown base`
2702
+ // and the bridge enters an infinite re-register loop.
2703
+ // Idempotent — append-only, skips configs that already have
2704
+ // the section. Does NOT restart AC servers; the operator
2705
+ // must click SERVER → Restart to load the new agent slug.
2706
+ const patched = [];
2707
+ try {
2708
+ const cfgAll = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
2709
+ for (const proj of cfgAll.projects || []) {
2710
+ if (!proj || !proj.id) continue;
2711
+ const acPath = projectAgentchattrConfigPath(proj.id);
2712
+ if (!fs.existsSync(acPath)) continue;
2713
+ try {
2714
+ const before = fs.readFileSync(acPath, "utf-8");
2715
+ const { text, changed } = patchAgentchattrConfigForTelegramBridge(before);
2716
+ if (changed) {
2717
+ fs.writeFileSync(acPath, text);
2718
+ patched.push(proj.id);
2719
+ }
2720
+ } catch {}
2721
+ }
2722
+ } catch {}
2723
+ return res.json({ ok: true, patched_projects: patched });
2539
2724
  }
2540
2725
  case "start": {
2541
2726
  const projectId = body.project_id;
@@ -2543,17 +2728,31 @@ router.post("/api/telegram", async (req, res) => {
2543
2728
  if (isTelegramRunning(projectId)) return res.json({ ok: true, running: true, message: "Already running" });
2544
2729
  const bridgeScript = path.join(BRIDGE_DIR, "telegram_bridge.py");
2545
2730
  if (!fs.existsSync(bridgeScript)) return res.json({ ok: false, error: "Bridge not installed. Click Install Bridge first." });
2731
+ // #380: resolve the dedicated venv's python3 by absolute path.
2732
+ // Do NOT activate the venv or set VIRTUAL_ENV in the parent —
2733
+ // calling the venv's python3 directly is sufficient because
2734
+ // Python's sys.executable bootstrap resolves the venv
2735
+ // automatically. See #379 research ticket.
2736
+ const venvPython = path.join(BRIDGE_DIR, ".venv", "bin", "python3");
2737
+ if (!fs.existsSync(venvPython)) {
2738
+ return res.json({
2739
+ ok: false,
2740
+ error: "Bridge venv missing. Click \"Install Bridge\" to create it.",
2741
+ });
2742
+ }
2546
2743
  const tg = getProjectTelegram(projectId);
2547
2744
  if (!tg || !tg.bot_token || !tg.chat_id) return res.json({ ok: false, error: "Save bot_token and chat_id in project settings first." });
2548
2745
  const tomlPath = telegramConfigToml(projectId);
2549
- const tomlContent = `[telegram]\nbot_token = "${tg.bot_token}"\nchat_id = "${tg.chat_id}"\n\n[agentchattr]\nurl = "${tg.agentchattr_url}"\n`;
2746
+ // #383 Bug 2: write agentchattr_url inside [telegram]; the
2747
+ // bridge's load_config only reads from that section.
2748
+ const tomlContent = buildTelegramBridgeToml(tg);
2550
2749
  fs.writeFileSync(tomlPath, tomlContent, { mode: 0o600 });
2551
2750
  fs.chmodSync(tomlPath, 0o600);
2552
2751
  // #353: pre-flight import check so a fresh install with no
2553
2752
  // `requests` module produces a readable error instead of the
2554
2753
  // Start → Running → Stopped flicker that the v1 code path
2555
2754
  // produced with `stdio: "ignore"`.
2556
- const depCheck = checkTelegramBridgePythonDeps();
2755
+ const depCheck = checkTelegramBridgePythonDeps(venvPython);
2557
2756
  if (!depCheck.ok) {
2558
2757
  // #372: persist the pre-flight failure to the bridge log
2559
2758
  // file so the GET /api/telegram `last_error` tail picks it
@@ -2562,8 +2761,8 @@ router.post("/api/telegram", async (req, res) => {
2562
2761
  // local error state, producing the "silent fail" symptom
2563
2762
  // (pill flips back to Stopped with no trace of why).
2564
2763
  const msg =
2565
- "Bridge Python dependencies not installed. Click \"Install Bridge\" to install them, " +
2566
- "or run: pip3 install -r " + path.join(BRIDGE_DIR, "requirements.txt") + "\n\n" +
2764
+ "Bridge Python dependencies not installed in the dedicated venv. " +
2765
+ "Click \"Install Bridge\" to (re)create the venv and install them.\n\n" +
2567
2766
  `Import error: ${depCheck.error}`;
2568
2767
  try {
2569
2768
  fs.writeFileSync(
@@ -2595,9 +2794,15 @@ router.post("/api/telegram", async (req, res) => {
2595
2794
  }
2596
2795
  let child;
2597
2796
  try {
2598
- child = spawn("python3", [bridgeScript, "--config", tomlPath], {
2797
+ // #383 Bug 4: scrub TELEGRAM_*/AGENTCHATTR_URL from the child
2798
+ // env so an operator shell that exports a different bot's
2799
+ // token (common on machines running AC2) can't silently
2800
+ // override the TOML. Makes the TOML the single source of
2801
+ // truth for the bridge's identity.
2802
+ child = spawn(venvPython, [bridgeScript, "--config", tomlPath], {
2599
2803
  detached: true,
2600
2804
  stdio: ["ignore", outFd, errFd],
2805
+ env: buildTelegramBridgeSpawnEnv(process.env),
2601
2806
  });
2602
2807
  child.unref();
2603
2808
  if (child.pid) fs.writeFileSync(telegramPidFile(projectId), String(child.pid));
@@ -2780,3 +2985,16 @@ module.exports.buildNoPrRow = buildNoPrRow;
2780
2985
  module.exports.summarizeItems = summarizeItems;
2781
2986
  // #353: expose readLastLines for the telegram-bridge test.
2782
2987
  module.exports.readLastLines = readLastLines;
2988
+ // #380: expose checkTelegramBridgePythonDeps so the bridge test can
2989
+ // exercise the venv-path interpreter argument round trip.
2990
+ module.exports.checkTelegramBridgePythonDeps = checkTelegramBridgePythonDeps;
2991
+ // #383: pure helpers exposed for unit tests in
2992
+ // routes.telegramBridge.test.js. No production callers outside
2993
+ // this file.
2994
+ module.exports.resolveProjectAgentchattrUrl = resolveProjectAgentchattrUrl;
2995
+ module.exports.buildTelegramBridgeToml = buildTelegramBridgeToml;
2996
+ module.exports.patchAgentchattrConfigForTelegramBridge = patchAgentchattrConfigForTelegramBridge;
2997
+ module.exports.buildTelegramBridgeSpawnEnv = buildTelegramBridgeSpawnEnv;
2998
+ // #236: expose sendViaWebSocket so the chat-ws-send regression test
2999
+ // can verify the ack/body/error paths against a fake AC ws server.
3000
+ module.exports.sendViaWebSocket = sendViaWebSocket;