vibe-coding-master 0.4.30 → 0.4.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,7 +33,7 @@ When Gate Review Gates are enabled for a task, or when a Gate Reviewer session a
33
33
  - VCM-managed root rules, role agents, repo-local VCM skills, Claude Code hooks, generated-context tools, and PR template.
34
34
  - Generated context for Rust and npm workspace module indexing plus public surface indexing.
35
35
  - Translation panel powered by the long-lived Translator session.
36
- - Mobile Gateway through Tencent iLink Bot API / Weixin DM, for talking to PM and managing tasks from Weixin.
36
+ - Mobile Gateway through Tencent iLink / Weixin DM or Lark, for talking to PM and managing tasks from a phone.
37
37
  - Durable task state, role session state, handoff artifacts, and message history.
38
38
 
39
39
  ## Requirements
@@ -221,7 +221,7 @@ The left sidebar is intentionally compact and collapsible:
221
221
  - `Settings`: `Theme`, `Flow pause alert`, `Try alert`, `Messages`, and `Events`.
222
222
  - `Translation`: global conversation translation, auto-send, target language, output scope, file translation, bootstrap, memory update, session status, and Translator session access.
223
223
  - `Gate Review Gates`: global gate switches for architecture plan, validation adequacy, and final diff.
224
- - `Gateway`: Weixin iLink binding, Gateway on/off, Gateway translation, and QR login.
224
+ - `Gateway`: Weixin iLink or Lark binding, Gateway on/off, Gateway translation, QR login, and Lark pairing.
225
225
  - `VCM Harness`: fixed-install status, bootstrap completion checks, and the bootstrap terminal when one is running.
226
226
  - `New Task`: one `task name` input.
227
227
  - `Tasks`: task list and task status.
@@ -238,11 +238,11 @@ When VCM is connected to an active task, the bottom of the sidebar shows a task
238
238
 
239
239
  ## Mobile Gateway
240
240
 
241
- VCM Gateway lets one Weixin DM identity bind to one desktop VCM instance. It is a mobile control surface for the current desktop VCM, not a remote terminal and not a group-chat bot.
241
+ VCM Gateway lets one mobile chat identity bind to one desktop VCM instance. It supports Weixin iLink and Lark. It is a mobile control surface for the current desktop VCM, not a remote terminal and not a multi-user collaboration surface.
242
242
 
243
243
  Gateway rules:
244
244
 
245
- - DM only; group chat is not supported.
245
+ - Weixin is DM only; Lark can receive group messages only when the bot is mentioned.
246
246
  - One phone identity binds to one desktop VCM instance.
247
247
  - The phone can manage projects and tasks available to that desktop VCM instance.
248
248
  - When the desktop UI has an active task selected, Gateway uses that task automatically.
@@ -273,7 +273,18 @@ Gateway state is stored locally under:
273
273
 
274
274
  The `Gateway` toggle is disabled until a QR login has produced a usable iLink token. After binding, VCM keeps receiving `/help`, `/start`, `/status`, `/projects`, and `/tasks` even when the toggle is off. Turning `Gateway` on, either from desktop or by sending `/start`, enables PM messages, task-changing commands, and PM reply push. `Reset Binding` clears the stored token and bound Weixin identity so the desktop VCM can bind again.
275
275
 
276
- When Gateway is turned on, VCM automatically turns off the browser `Flow pause alert` and disables `Try alert`. Gateway becomes the notification path, so the browser should not show blocking flow-pause dialogs while the user is managing the task from Weixin.
276
+ ### Bind Lark
277
+
278
+ 1. Create a Lark app with bot capability, message receive events, and WebSocket event delivery.
279
+ 2. Open the sidebar `Gateway` section and select `Lark`.
280
+ 3. Save the Lark App ID and App Secret.
281
+ 4. Click `Create Pairing Code`.
282
+ 5. Send `/bind CODE` to the Lark bot before the code expires.
283
+ 6. After binding succeeds, turn `Gateway` on in the sidebar.
284
+
285
+ Lark credentials stay in local VCM state. The App Secret is not shown again in the UI. `Reset Binding` clears the bound Lark user/chat state while keeping the saved Lark app credentials.
286
+
287
+ When Gateway is turned on, VCM automatically turns off the browser `Flow pause alert` and disables `Try alert`. Gateway becomes the notification path, so the browser should not show blocking flow-pause dialogs while the user is managing the task from the phone.
277
288
 
278
289
  ### Translation
279
290
 
@@ -281,17 +292,17 @@ The Gateway section has its own `Translation` toggle.
281
292
 
282
293
  When Gateway translation is on:
283
294
 
284
- - Chinese Weixin input is translated to English before being submitted to PM.
295
+ - Mobile input is translated to English before being submitted to PM.
285
296
  - The prompt sent to PM includes only the translated English text with a `[VCM Gateway]` marker.
286
297
  - The original Chinese text is not included in the PM prompt.
287
- - PM replies are translated back to Chinese before VCM sends them to Weixin.
298
+ - PM replies are translated before VCM sends them to the bound mobile chat.
288
299
  - If PM reply translation fails or times out, VCM sends a translation failure notice instead of the English source. The user can send `/retry` to retry the latest failed Gateway output translation.
289
300
 
290
- When Gateway translation is off, plain Weixin text is sent to PM as-is.
301
+ When Gateway translation is off, plain mobile text is sent to PM as-is.
291
302
 
292
303
  ### Commands
293
304
 
294
- After Gateway is bound, send commands in the bound Weixin DM.
305
+ After Gateway is bound, send commands in the bound mobile conversation.
295
306
 
296
307
  When `Gateway` is off, only these commands are accepted:
297
308
 
@@ -343,7 +354,7 @@ Typical mobile flow:
343
354
 
344
355
  - `/status`: shows Gateway, binding, translation, current project, current task, and last poll status.
345
356
  - `/status` also adopts the current desktop project/task when one is selected.
346
- - `/start`: turns Gateway on from the bound Weixin DM so full mobile task operations and PM messages are allowed. If the current task has a cached latest PM reply, `/start` includes it in the response.
357
+ - `/start`: turns Gateway on from the bound mobile conversation so full mobile task operations and PM messages are allowed. If the current task has a cached latest PM reply, `/start` includes it in the response.
347
358
  - `/retry`: retries the latest failed Gateway output translation in the current VCM process.
348
359
  - `/projects`: lists the current/recent repositories known by the desktop VCM.
349
360
  - `/use-project <index-or-path>`: selects the Gateway's current project context.
@@ -366,12 +377,12 @@ Typical mobile flow:
366
377
  - If the QR dialog does not appear, refresh the page and click `Start QR Login` again.
367
378
  - If the QR status stays `wait`, confirm the login on the phone and click `Confirm` again.
368
379
  - If the QR code expires, start a new QR login.
369
- - If `Gateway` cannot be enabled, bind Weixin first.
370
- - If `/start` or read-only commands do not receive replies, check that the iLink token has not expired and the Weixin DM is the bound identity.
380
+ - If `Gateway` cannot be enabled, bind Weixin or pair Lark first.
381
+ - If `/start` or read-only commands do not receive replies, check that the selected channel is connected and the message comes from the bound identity.
371
382
  - If PM messages or task-changing commands are rejected, check that Gateway is on.
372
383
  - If plain text cannot be sent to PM, select a project and task first, and make sure the task's PM session is running and idle.
373
384
  - If PM replies are not pushed, check that Gateway is on and the PM session is producing normal Claude transcript output.
374
- - If PM reply translation fails, send `/retry` from the bound Weixin DM. Retry state is memory-only and is cleared when VCM restarts.
385
+ - If PM reply translation fails, send `/retry` from the bound mobile conversation. Retry state is memory-only and is cleared when VCM restarts.
375
386
 
376
387
  ## Translation
377
388
 
@@ -393,7 +404,7 @@ The sidebar `Settings` section also stores the UI theme preference in this file.
393
404
 
394
405
  The same sidebar also has a `Flow pause alert` toggle. It is on by default and controls the local alert that fires when VCM detects that the current role flow has stopped advancing. Short flows use a weak reminder: the soft two-note chime plays 3 times, 1.4 seconds apart. Flows lasting 2 minutes or longer use a strong reminder: VCM shows an alert dialog and repeats the chime until the user confirms it. The alert sound reuses one browser audio context so repeated reminders remain reliable in stricter browsers such as Safari. Safari users may still need to manually set `Safari > Website Settings > Auto-Play > Allow All Auto-Play`; Chrome is recommended for the most reliable alert sound behavior. The `Try alert` button always triggers the strong reminder for testing.
395
406
 
396
- When Gateway is on, `Flow pause alert` is forced off because mobile notifications are delivered through Weixin and browser alerts can block normal workflow progress.
407
+ When Gateway is on, `Flow pause alert` is forced off because mobile notifications are delivered through Gateway and browser alerts can block normal workflow progress.
397
408
 
398
409
  Translation behavior:
399
410
 
@@ -135,6 +135,28 @@ export function createGitAdapter(runner) {
135
135
  }
136
136
  return result.stdout;
137
137
  },
138
+ async getDiff(repoRoot, baseRef, headRef = null, paths = []) {
139
+ const result = await runGit(runner, repoRoot, [
140
+ "diff",
141
+ "--no-ext-diff",
142
+ "--binary",
143
+ "--src-prefix=a/",
144
+ "--dst-prefix=b/",
145
+ baseRef,
146
+ ...(headRef ? [headRef] : []),
147
+ "--",
148
+ ...paths
149
+ ]);
150
+ if (result.exitCode !== 0) {
151
+ throw new VcmError({
152
+ code: "GIT_ERROR",
153
+ message: "Unable to read Git diff.",
154
+ statusCode: 400,
155
+ hint: result.stderr
156
+ });
157
+ }
158
+ return result.stdout;
159
+ },
138
160
  async getCommitList(repoRoot, range) {
139
161
  const result = await runGit(runner, repoRoot, ["log", "--format=%H%x00%s%x00%cI%x1e", range]);
140
162
  if (result.exitCode !== 0) {
@@ -8,6 +8,9 @@ export function registerGatewayRoutes(app, deps) {
8
8
  app.post("/api/gateway/qr/start", async () => {
9
9
  return deps.gatewayService.startQrLogin();
10
10
  });
11
+ app.post("/api/gateway/pairing-code", async () => {
12
+ return deps.gatewayService.createPairingCode();
13
+ });
11
14
  app.post("/api/gateway/qr/check", async (request) => {
12
15
  return deps.gatewayService.checkQrLogin(request.body);
13
16
  });
@@ -38,6 +38,13 @@ export function registerHarnessRoutes(app, deps) {
38
38
  commitSha: request.query.commit
39
39
  });
40
40
  });
41
+ app.get("/api/projects/harness/repository-diff/file", async (request) => {
42
+ const { project, task } = await requireHarnessTaskContext(deps, request.query.taskSlug);
43
+ return deps.harnessService.getRepositoryFileDiff(task.worktreePath, {
44
+ baseRepoRoot: project.repoRoot,
45
+ path: request.query.path ?? ""
46
+ });
47
+ });
41
48
  app.post("/api/projects/harness/repository-diff/merge-to-current-branch", async (request) => {
42
49
  const { project, task } = await requireHarnessTaskContext(deps, request.body?.taskSlug);
43
50
  return deps.harnessService.mergeRepositoryDiffToCurrentBranch(project.repoRoot, {
@@ -0,0 +1,254 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const DEFAULT_BASE_URL = "lark://open-platform";
3
+ const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
4
+ export function createLarkChannel(options = {}) {
5
+ let connection = null;
6
+ async function ensureConnection(input) {
7
+ const key = `${input.appId}:${input.appSecret}`;
8
+ if (connection?.key === key) {
9
+ return connection;
10
+ }
11
+ connection?.wsClient?.close({ force: true });
12
+ const Lark = await import("@larksuiteoapi/node-sdk");
13
+ const queue = [];
14
+ const waiters = [];
15
+ const state = {
16
+ key,
17
+ wsClient: null,
18
+ client: null,
19
+ queue,
20
+ waiters,
21
+ ready: Promise.resolve(),
22
+ error: null
23
+ };
24
+ const client = new Lark.Client({
25
+ appId: input.appId,
26
+ appSecret: input.appSecret,
27
+ domain: options.domain ?? "lark"
28
+ });
29
+ const dispatcher = new Lark.EventDispatcher({}).register({
30
+ "im.message.receive_v1": async (data) => {
31
+ for (const update of parseLarkMessageEvent(data)) {
32
+ queue.push(update);
33
+ }
34
+ while (waiters.length > 0) {
35
+ waiters.shift()?.();
36
+ }
37
+ }
38
+ });
39
+ const ready = new Promise((resolve, reject) => {
40
+ const wsClient = new Lark.WSClient({
41
+ appId: input.appId,
42
+ appSecret: input.appSecret,
43
+ domain: options.domain ?? "lark",
44
+ source: "vcm-gateway",
45
+ autoReconnect: true,
46
+ loggerLevel: options.loggerLevel,
47
+ onReady: () => resolve(),
48
+ onError: (error) => {
49
+ state.error = error;
50
+ reject(error);
51
+ while (waiters.length > 0) {
52
+ waiters.shift()?.();
53
+ }
54
+ }
55
+ });
56
+ state.wsClient = wsClient;
57
+ state.client = client;
58
+ void wsClient.start({ eventDispatcher: dispatcher }).catch((error) => {
59
+ state.error = error;
60
+ reject(error);
61
+ while (waiters.length > 0) {
62
+ waiters.shift()?.();
63
+ }
64
+ });
65
+ });
66
+ state.ready = ready;
67
+ connection = state;
68
+ return state;
69
+ }
70
+ return {
71
+ id: "lark",
72
+ label: "Lark",
73
+ defaultBaseUrl: DEFAULT_BASE_URL,
74
+ supportsQrLogin: false,
75
+ async getUpdates(input) {
76
+ const appId = input.account.appId?.trim();
77
+ const appSecret = input.account.appSecret?.trim();
78
+ if (!appId || !appSecret) {
79
+ throw new Error("Lark App ID and App Secret are required.");
80
+ }
81
+ const state = await ensureConnection({ appId, appSecret });
82
+ await state.ready;
83
+ if (state.error) {
84
+ throw state.error;
85
+ }
86
+ const timeoutMs = input.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
87
+ const updates = await waitForUpdates(state, timeoutMs, input.signal);
88
+ if (input.signal?.aborted && connection === state) {
89
+ connection = null;
90
+ }
91
+ return {
92
+ cursor: new Date().toISOString(),
93
+ timeoutMs,
94
+ updates
95
+ };
96
+ },
97
+ async sendText(input) {
98
+ const appId = input.account.appId?.trim();
99
+ const appSecret = input.account.appSecret?.trim();
100
+ if (!appId || !appSecret) {
101
+ throw new Error("Lark App ID and App Secret are required.");
102
+ }
103
+ const state = await ensureConnection({ appId, appSecret });
104
+ await state.ready;
105
+ if (!state.client) {
106
+ throw new Error("Lark client is not initialized.");
107
+ }
108
+ const receiveId = input.chatId || input.account.homeChatId || input.toUserId;
109
+ const receiveIdType = input.chatId || input.account.homeChatId ? "chat_id" : "open_id";
110
+ if (!receiveId) {
111
+ throw new Error("Lark send target is missing.");
112
+ }
113
+ const result = await state.client.im.v1.message.create({
114
+ params: {
115
+ receive_id_type: receiveIdType
116
+ },
117
+ data: {
118
+ receive_id: receiveId,
119
+ msg_type: "text",
120
+ content: JSON.stringify({ text: input.text }),
121
+ uuid: `vcm-gateway-${randomUUID()}`
122
+ }
123
+ });
124
+ return result?.data?.message_id ?? `vcm-gateway-${randomUUID()}`;
125
+ },
126
+ isSessionExpiredError() {
127
+ return false;
128
+ }
129
+ };
130
+ }
131
+ async function waitForUpdates(state, timeoutMs, signal) {
132
+ if (state.queue.length > 0) {
133
+ return state.queue.splice(0);
134
+ }
135
+ if (signal?.aborted) {
136
+ state.wsClient?.close({ force: true });
137
+ return [];
138
+ }
139
+ await new Promise((resolve) => {
140
+ let settled = false;
141
+ let timeout;
142
+ const finish = () => {
143
+ if (settled) {
144
+ return;
145
+ }
146
+ settled = true;
147
+ clearTimeout(timeout);
148
+ const index = state.waiters.indexOf(waiter);
149
+ if (index >= 0) {
150
+ state.waiters.splice(index, 1);
151
+ }
152
+ signal?.removeEventListener("abort", finish);
153
+ resolve();
154
+ };
155
+ const waiter = finish;
156
+ timeout = setTimeout(finish, timeoutMs);
157
+ state.waiters.push(waiter);
158
+ signal?.addEventListener("abort", finish, { once: true });
159
+ });
160
+ if (state.error) {
161
+ throw state.error;
162
+ }
163
+ if (signal?.aborted) {
164
+ state.wsClient?.close({ force: true });
165
+ return [];
166
+ }
167
+ return state.queue.splice(0);
168
+ }
169
+ function parseLarkMessageEvent(data) {
170
+ if (!isObject(data)) {
171
+ return [];
172
+ }
173
+ const event = isObject(data.event) ? data.event : data;
174
+ if (!isObject(event)) {
175
+ return [];
176
+ }
177
+ const message = isObject(event.message) ? event.message : {};
178
+ const sender = isObject(event.sender) ? event.sender : {};
179
+ const senderId = isObject(sender.sender_id) ? sender.sender_id : {};
180
+ const fromUserId = stringOrUndefined(senderId.open_id)
181
+ ?? stringOrUndefined(senderId.user_id)
182
+ ?? stringOrUndefined(senderId.union_id);
183
+ const messageId = stringOrUndefined(message.message_id);
184
+ const chatId = stringOrUndefined(message.chat_id);
185
+ const chatType = stringOrUndefined(message.chat_type);
186
+ const rawText = extractTextContent(message.content);
187
+ if (!fromUserId || !messageId || !rawText.trim()) {
188
+ return [];
189
+ }
190
+ const mentions = Array.isArray(message.mentions) ? message.mentions : [];
191
+ const isDirect = chatType === "p2p";
192
+ if (!isDirect && mentions.length === 0) {
193
+ return [];
194
+ }
195
+ const text = stripMentionTokens(rawText, mentions);
196
+ if (!text.trim()) {
197
+ return [];
198
+ }
199
+ const createdAt = normalizeLarkTimestamp(message.create_time);
200
+ return [{
201
+ messageId,
202
+ fromUserId,
203
+ chatId,
204
+ chatType: isDirect ? "dm" : "group",
205
+ text,
206
+ createdAt,
207
+ raw: data
208
+ }];
209
+ }
210
+ function extractTextContent(input) {
211
+ if (typeof input !== "string") {
212
+ return "";
213
+ }
214
+ try {
215
+ const parsed = JSON.parse(input);
216
+ if (isObject(parsed) && typeof parsed.text === "string") {
217
+ return parsed.text;
218
+ }
219
+ }
220
+ catch {
221
+ return input;
222
+ }
223
+ return input;
224
+ }
225
+ function stripMentionTokens(text, mentions) {
226
+ let out = text;
227
+ for (const mention of mentions) {
228
+ if (!isObject(mention)) {
229
+ continue;
230
+ }
231
+ const key = stringOrUndefined(mention.key);
232
+ if (key) {
233
+ out = out.replaceAll(key, "");
234
+ }
235
+ }
236
+ return out.replace(/<at\b[^>]*>.*?<\/at>/g, "").trim();
237
+ }
238
+ function normalizeLarkTimestamp(input) {
239
+ const raw = stringOrUndefined(input);
240
+ if (!raw) {
241
+ return undefined;
242
+ }
243
+ const timestamp = Number.parseInt(raw, 10);
244
+ if (!Number.isFinite(timestamp)) {
245
+ return undefined;
246
+ }
247
+ return new Date(timestamp).toISOString();
248
+ }
249
+ function stringOrUndefined(input) {
250
+ return typeof input === "string" && input.trim() ? input.trim() : undefined;
251
+ }
252
+ function isObject(value) {
253
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
254
+ }
@@ -92,10 +92,14 @@ export function createWeixinIlinkChannel(options = {}) {
92
92
  });
93
93
  }
94
94
  return {
95
+ id: "weixin-ilink",
96
+ label: "Weixin iLink",
97
+ defaultBaseUrl: DEFAULT_BASE_URL,
98
+ supportsQrLogin: true,
95
99
  async startQrLogin(input) {
96
100
  const payload = await post({
97
101
  requestBaseUrl: baseUrl,
98
- endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(input.botType ?? DEFAULT_BOT_TYPE)}`,
102
+ endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(DEFAULT_BOT_TYPE)}`,
99
103
  body: {
100
104
  local_token_list: input.localTokenList ?? []
101
105
  },
@@ -134,7 +138,7 @@ export function createWeixinIlinkChannel(options = {}) {
134
138
  const payload = await post({
135
139
  requestBaseUrl: input.account.baseUrl,
136
140
  endpoint: "ilink/bot/getupdates",
137
- token: input.account.token,
141
+ token: input.account.token ?? undefined,
138
142
  timeoutMs: input.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS,
139
143
  label: "getupdates",
140
144
  signal: input.signal,
@@ -160,7 +164,7 @@ export function createWeixinIlinkChannel(options = {}) {
160
164
  const payload = await post({
161
165
  requestBaseUrl: input.account.baseUrl,
162
166
  endpoint: "ilink/bot/sendmessage",
163
- token: input.account.token,
167
+ token: input.account.token ?? undefined,
164
168
  label: "sendmessage",
165
169
  body: {
166
170
  msg: {
@@ -184,6 +188,9 @@ export function createWeixinIlinkChannel(options = {}) {
184
188
  });
185
189
  assertIlinkOk(payload, "sendmessage");
186
190
  return clientId;
191
+ },
192
+ isSessionExpiredError(error) {
193
+ return error instanceof Error && error.message.toLowerCase().includes("expired");
187
194
  }
188
195
  };
189
196
  }
@@ -0,0 +1,16 @@
1
+ export function createGatewayChannelRegistry(channels) {
2
+ if (channels.length === 0) {
3
+ throw new Error("At least one gateway channel must be registered.");
4
+ }
5
+ const byId = new Map();
6
+ for (const channel of channels) {
7
+ byId.set(channel.id, channel);
8
+ }
9
+ const defaultChannel = channels[0];
10
+ return {
11
+ defaultChannel,
12
+ get(channel) {
13
+ return byId.get(channel) ?? defaultChannel;
14
+ }
15
+ };
16
+ }