vibe-coding-master 0.2.6 → 0.2.7

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
@@ -29,6 +29,7 @@ Each role runs as a real Claude Code process inside an embedded terminal. The GU
29
29
  - VCM-managed root rules, four role agents, repo-local VCM skills, Claude Code hooks, generated-context tools, and PR template.
30
30
  - Rust generated context for module indexing and crate-external public surface indexing.
31
31
  - Translation panel powered by an OpenAI-compatible low-cost model.
32
+ - Mobile Gateway through Tencent iLink Bot API / Weixin DM, for talking to PM and managing tasks from Weixin.
32
33
  - Durable task state, session state, raw terminal logs, handoff artifacts, and message history.
33
34
 
34
35
  ## Requirements
@@ -157,6 +158,26 @@ project-manager
157
158
  -> project-manager final acceptance, commit, and PR
158
159
  ```
159
160
 
161
+ ## VCM Runtime Terminology
162
+
163
+ VCM statistics use three nested terms:
164
+
165
+ ```text
166
+ Session = n x Round = m x Turn
167
+ ```
168
+
169
+ - `Session`: the whole VCM task. It aggregates all statistics for that task, from task creation until the task is closed.
170
+ - `Round`: one user-facing VCM conversation cycle. It starts when a user prompt or VCM-delivered prompt is accepted, continues through automatic role orchestration, and normally ends when the final `project-manager` result has stopped and no next role turn starts inside the 10 second stop window.
171
+ - `Turn`: one role-level Claude Code conversation. A turn starts when VCM or the user submits a prompt to one role session and ends when that Claude Code process emits `Stop`.
172
+
173
+ Turns inside one Round are strictly sequential. VCM should finish one role Turn before starting the next role Turn in that Round.
174
+
175
+ Session state is intentionally small: `created` means no Round has started yet, `running` means there is a current running Round, and `stopped` means there is no current running Round after at least one Round has stopped. Starting Claude Code role sessions does not make the VCM Session `running`.
176
+
177
+ Round state is only `running` or `stopped`. After a `Stop` hook, the 10 second stop window still counts as `running`; the Round becomes `stopped` only when the timer expires without another `UserPromptSubmit`.
178
+
179
+ In this terminology, `Session` is a VCM statistics term. It is different from a Claude Code role session. VCM still runs one Claude Code role session per role, but the sidebar task statistics treat the task itself as the VCM Session.
180
+
160
181
  ## Task Worktree Management
161
182
 
162
183
  VCM uses task-level worktree management by default:
@@ -189,6 +210,7 @@ The left sidebar is intentionally compact and collapsible:
189
210
  - `Repository Path`: path input on one row; `Recent` and `Connect` on the next row.
190
211
  - `Connected Repository`: connected base repo path, branch, upstream/ahead-behind status, commit hash, working tree state, and a `Pull` button.
191
212
  - `Settings`: `Theme`, `Flow pause alert`, `Try alert`, `Messages`, and `Events`.
213
+ - `Gateway`: Weixin iLink binding, Gateway on/off, Gateway translation, QR login, and recent Gateway status.
192
214
  - `VCM Harness`: fixed-install status, bootstrap completion checks, and the bootstrap terminal when one is running.
193
215
  - `New Task`: one `task name` input.
194
216
  - `Tasks`: task list and task status.
@@ -202,7 +224,123 @@ the base repo has uncommitted changes, when the current branch has no upstream,
202
224
  or when the active task is an inline task using the base repo directly. It does
203
225
  not stash, merge, or mutate task worktrees.
204
226
 
205
- When VCM is connected to an active task, the bottom of the sidebar shows a task status dock. It stays outside the collapsible groups and shows the task title, task status, start time, total elapsed time, total flow-round count, and Claude Code active runtime. If a flow round is currently active or settling, the dock also shows that round's start time and Claude Code active runtime.
227
+ When VCM is connected to an active task, the bottom of the sidebar shows a task status dock. It stays outside the collapsible groups and shows the active VCM Session title, task status, start time, total elapsed time, total Round count, and Claude Code active runtime. If a Round is currently running, the dock also shows the Current Round start time, total elapsed time, Claude Code active runtime, and Turn count.
228
+
229
+ ## Mobile Gateway
230
+
231
+ 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.
232
+
233
+ Gateway rules:
234
+
235
+ - DM only; group chat is not supported.
236
+ - One phone identity binds to one desktop VCM instance.
237
+ - The phone can manage projects and tasks available to that desktop VCM instance.
238
+ - When the desktop UI has an active task selected, Gateway uses that task automatically.
239
+ - Plain text messages go only to the current task's `project-manager`.
240
+ - Gateway never sends directly to `architect`, `coder`, or `reviewer`.
241
+ - Gateway credentials and audit logs stay in local app state, not in connected repositories.
242
+
243
+ Gateway state is stored locally under:
244
+
245
+ ```text
246
+ ~/.vcm/gateway/settings.json
247
+ ~/.vcm/gateway/audit.jsonl
248
+ ```
249
+
250
+ ### Bind Weixin
251
+
252
+ 1. Start VCM and open the GUI.
253
+ 2. Open the sidebar `Gateway` section.
254
+ 3. Click `Start QR Login`.
255
+ 4. VCM opens a global `Weixin Gateway Login` dialog with a QR code.
256
+ 5. Scan the QR code with Weixin and confirm login on the phone.
257
+ 6. Click `Check QR` in the dialog.
258
+ 7. After binding succeeds, close the dialog and turn `Gateway` on in the sidebar.
259
+
260
+ `Start QR Login` creates a new Tencent iLink QR login session. `Check QR` asks iLink whether the QR code has been scanned and confirmed. If the QR code expires, click `Start QR Login` again.
261
+
262
+ The `Gateway` toggle is disabled until a QR login has produced a usable iLink token. `Reset Binding` clears the stored token and bound Weixin identity so the desktop VCM can bind again.
263
+
264
+ 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.
265
+
266
+ ### Translation
267
+
268
+ The Gateway section has its own `Translation` toggle.
269
+
270
+ When Gateway translation is on:
271
+
272
+ - Chinese Weixin input is translated to English before being submitted to PM.
273
+ - The prompt sent to PM includes only the translated English text with a `[VCM Gateway]` marker.
274
+ - The original Chinese text is not included in the PM prompt.
275
+ - PM replies are translated back to Chinese before VCM sends them to Weixin.
276
+
277
+ When Gateway translation is off, plain Weixin text is sent to PM as-is.
278
+
279
+ ### Commands
280
+
281
+ After Gateway is bound and turned on, send commands in the bound Weixin DM:
282
+
283
+ ```text
284
+ /help
285
+ /status
286
+ /projects
287
+ /use-project <index-or-path>
288
+ /pull-current
289
+ /tasks
290
+ /use-task <index-or-task-slug>
291
+ /create-task <task-slug> [title]
292
+ /close-task
293
+ /close-task confirm <task-slug>
294
+ /translate on
295
+ /translate off
296
+ ```
297
+
298
+ Plain text that does not start with `/` is sent as a message to the current task's PM session.
299
+
300
+ In normal use, VCM runs one project and one task at a time. If the desktop UI has a task selected, turning Gateway on syncs that project/task into Gateway automatically. `/status` also refreshes this context, so the phone usually does not need to run `/tasks` and `/use-task` before sending a PM message.
301
+
302
+ Typical mobile flow:
303
+
304
+ ```text
305
+ /projects
306
+ /use-project 1
307
+ /pull-current
308
+ /create-task mobile-demo "Implement mobile gateway smoke test"
309
+ /status
310
+ 继续推进这个任务,先让 PM 安排下一步。
311
+ /close-task
312
+ /close-task confirm mobile-demo
313
+ ```
314
+
315
+ ### Command Behavior
316
+
317
+ - `/status`: shows Gateway, binding, translation, current project, current task, and last poll status.
318
+ - `/status` also adopts the current desktop project/task when one is selected.
319
+ - `/projects`: lists the current/recent repositories known by the desktop VCM.
320
+ - `/use-project <index-or-path>`: selects the Gateway's current project context.
321
+ - `/pull-current`: runs the same fast-forward-only connected repository pull as the desktop `Pull` button.
322
+ - `/tasks`: lists tasks for the selected project.
323
+ - `/use-task <index-or-task-slug>`: selects the Gateway's current task context.
324
+ - `/create-task <task-slug> [title]`: creates a worktree-backed task and starts the four role sessions using the saved launch template.
325
+ - `/close-task`: starts a destructive close confirmation for the current task.
326
+ - `/close-task confirm <task-slug>`: closes the task through VCM cleanup after exact slug confirmation.
327
+ - `/translate on` and `/translate off`: changes Gateway translation for mobile messages.
328
+
329
+ `/pull-current` only pulls the connected base repository. It does not pull task worktrees, stash local changes, merge divergent branches, or run arbitrary shell commands.
330
+
331
+ `/create-task` uses the saved launch template from the desktop settings. The template controls permission mode, model, auto orchestration, and translation defaults for the four role sessions.
332
+
333
+ `/close-task` is destructive. It stops VCM-managed role sessions and removes task-owned worktree/branch state according to the same cleanup behavior as the desktop `Close Task` action.
334
+
335
+ ### Troubleshooting
336
+
337
+ - If the QR dialog does not appear, refresh the page and click `Start QR Login` again.
338
+ - If the QR status stays `wait`, confirm the login on the phone and click `Check QR` again.
339
+ - If the QR code expires, start a new QR login.
340
+ - If `Gateway` cannot be enabled, bind Weixin first.
341
+ - If messages are not received, check that Gateway is on, the iLink token has not expired, and the Weixin DM is the bound identity.
342
+ - 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.
343
+ - If PM replies are not pushed, check that Gateway is on and the PM session is producing normal Claude transcript output.
206
344
 
207
345
  ## Translation
208
346
 
@@ -222,6 +360,8 @@ The sidebar `Settings` section also stores the UI theme preference in this file.
222
360
 
223
361
  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.
224
362
 
363
+ 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.
364
+
225
365
  Translation behavior:
226
366
 
227
367
  - Provider type is OpenAI-compatible chat completions.
@@ -386,13 +526,15 @@ The implementation keeps only the active manual/auto orchestration mode. It does
386
526
 
387
527
  ## Flow Pause Alerts
388
528
 
389
- VCM detects flow pauses from Claude Code hook events, not PTY silence, message history, or pending route files. `UserPromptSubmit` starts a new flow round when none is active, or continues the current round when it fires within the settle window.
529
+ VCM detects flow pauses from Claude Code hook events, not PTY silence, message history, or pending route files. `UserPromptSubmit` starts a new Round when there is no current running Round, or continues the current Round when it fires within the 10 second stop window.
530
+
531
+ When a `Stop` hook fires, VCM ends the current Turn and starts a 10 second timer. During that timer, the Round is still `running`. If another `UserPromptSubmit` fires inside that window, the same Round continues and VCM increments the Turn count. If no new prompt is accepted before the deadline, VCM marks the Round as `stopped`.
390
532
 
391
- When a `Stop` hook fires, VCM marks the round as settling for 10 seconds. If another `UserPromptSubmit` fires inside that window, the same round continues and VCM increments the prompt-submit count. If no new prompt is accepted before the deadline, VCM marks the round as `paused`.
533
+ The normal path is timer-driven: `Stop` starts a backend timer, and `UserPromptSubmit` cancels it.
392
534
 
393
- The normal path is timer-driven: `Stop` starts a backend settle timer, and `UserPromptSubmit` cancels it. Reading the round state also checks expired deadlines as a recovery fallback after process restarts, sleep, or missed timers.
535
+ When `Flow pause alert` is enabled, the frontend polls the task Round state and deduplicates each stopped Round so the same stopped state does not alert on every poll. Flow duration is measured from the first `UserPromptSubmit` to `stoppedAt`, falling back to the last `Stop` when needed. If the flow lasted less than 2 minutes, it plays the local chime 3 times at 1.4 second intervals. If the flow lasted 2 minutes or longer, it shows a modal alert and repeats the local chime until the user clicks `Confirm`. A stopped Round can mean normal completion, user decision needed, dispatch failure, or another workflow interruption; the point is to get the user to look with the right amount of urgency.
394
536
 
395
- When `Flow pause alert` is enabled, the frontend polls the task round state and deduplicates each paused round so the same paused state does not alert on every poll. Flow duration is measured from the first `UserPromptSubmit` to `pausedAt`, falling back to the last `Stop` when needed. If the paused flow lasted less than 2 minutes, it plays the local chime 3 times at 1.4 second intervals. If the paused flow lasted 2 minutes or longer, it shows a modal `Flow paused` alert and repeats the local chime until the user clicks `Confirm`. A pause can mean normal completion, user decision needed, dispatch failure, or another workflow interruption; the point is to get the user to look with the right amount of urgency.
537
+ The Current Round dock separates wall-clock Round duration from Claude Code active runtime. `Total` is `now - Round.startedAt`; `CC runtime` is only the accumulated time between each Turn's `UserPromptSubmit` and `Stop`; `Turn count` is the number of accepted prompts inside the current Round.
396
538
 
397
539
  ## Resume Behavior
398
540
 
@@ -0,0 +1,17 @@
1
+ export function registerGatewayRoutes(app, deps) {
2
+ app.get("/api/gateway/status", async () => {
3
+ return deps.gatewayService.getStatus();
4
+ });
5
+ app.put("/api/gateway/settings", async (request) => {
6
+ return deps.gatewayService.updateSettings(request.body);
7
+ });
8
+ app.post("/api/gateway/qr/start", async () => {
9
+ return deps.gatewayService.startQrLogin();
10
+ });
11
+ app.post("/api/gateway/qr/check", async (request) => {
12
+ return deps.gatewayService.checkQrLogin(request.body);
13
+ });
14
+ app.post("/api/gateway/binding/reset", async () => {
15
+ return deps.gatewayService.resetBinding();
16
+ });
17
+ }
@@ -6,7 +6,7 @@ export function registerRoundRoutes(app, deps) {
6
6
  const config = await deps.projectService.loadConfig(project.repoRoot);
7
7
  const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
8
8
  const taskRepoRoot = getTaskRuntimeRepoRoot(task);
9
- return deps.roundService.getTaskRoundState({
9
+ return deps.roundService.getSessionRoundState({
10
10
  stateRepoRoot: taskRepoRoot,
11
11
  stateRoot: config.stateRoot,
12
12
  taskSlug: request.params.taskSlug
@@ -0,0 +1,304 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
3
+ const DEFAULT_BOT_TYPE = "3";
4
+ const DEFAULT_CHANNEL_VERSION = "2.4.3";
5
+ const DEFAULT_API_TIMEOUT_MS = 15_000;
6
+ const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
7
+ const SESSION_EXPIRED_ERRCODE = -14;
8
+ const MESSAGE_TYPE_USER = 1;
9
+ const MESSAGE_TYPE_BOT = 2;
10
+ const MESSAGE_STATE_FINISH = 2;
11
+ const MESSAGE_ITEM_TEXT = 1;
12
+ export function createWeixinIlinkChannel(options = {}) {
13
+ const fetchImpl = options.fetchImpl ?? fetch;
14
+ const baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
15
+ const channelVersion = options.channelVersion ?? DEFAULT_CHANNEL_VERSION;
16
+ const botAgent = options.botAgent ?? "vcm-gateway/0.1.0";
17
+ const appId = options.appId ?? "bot";
18
+ const apiTimeoutMs = options.apiTimeoutMs ?? DEFAULT_API_TIMEOUT_MS;
19
+ function buildBaseInfo() {
20
+ return {
21
+ channel_version: channelVersion,
22
+ bot_agent: botAgent
23
+ };
24
+ }
25
+ function buildIlinkAppHeaders() {
26
+ const headers = {
27
+ "iLink-App-Id": appId,
28
+ "iLink-App-ClientVersion": String(buildClientVersion(channelVersion))
29
+ };
30
+ if (options.routeTag) {
31
+ headers.SKRouteTag = options.routeTag;
32
+ }
33
+ return headers;
34
+ }
35
+ function buildJsonHeaders(token) {
36
+ const headers = {
37
+ "Content-Type": "application/json",
38
+ AuthorizationType: "ilink_bot_token",
39
+ "X-WECHAT-UIN": randomWechatUin(),
40
+ ...buildIlinkAppHeaders()
41
+ };
42
+ if (token?.trim()) {
43
+ headers.Authorization = `Bearer ${token.trim()}`;
44
+ }
45
+ return headers;
46
+ }
47
+ async function fetchJson(input) {
48
+ const controller = input.timeoutMs !== undefined ? new AbortController() : undefined;
49
+ const timeout = controller ? setTimeout(() => controller.abort(), input.timeoutMs) : undefined;
50
+ const signal = combineSignals(controller?.signal, input.signal);
51
+ const url = new URL(input.endpoint, ensureTrailingSlash(input.requestBaseUrl));
52
+ try {
53
+ const response = await fetchImpl(url, {
54
+ method: input.method,
55
+ headers: input.jsonHeaders === false ? buildIlinkAppHeaders() : buildJsonHeaders(input.token),
56
+ body: input.body === undefined ? undefined : JSON.stringify(input.body),
57
+ signal
58
+ });
59
+ const rawText = await response.text();
60
+ if (!response.ok) {
61
+ throw new Error(`${input.label} HTTP ${response.status}: ${rawText}`);
62
+ }
63
+ return rawText ? JSON.parse(rawText) : {};
64
+ }
65
+ finally {
66
+ if (timeout) {
67
+ clearTimeout(timeout);
68
+ }
69
+ }
70
+ }
71
+ async function post(input) {
72
+ return fetchJson({
73
+ method: "POST",
74
+ requestBaseUrl: input.requestBaseUrl,
75
+ endpoint: input.endpoint,
76
+ token: input.token,
77
+ body: input.body,
78
+ timeoutMs: input.timeoutMs ?? apiTimeoutMs,
79
+ label: input.label,
80
+ signal: input.signal
81
+ });
82
+ }
83
+ async function get(input) {
84
+ return fetchJson({
85
+ method: "GET",
86
+ requestBaseUrl: input.requestBaseUrl,
87
+ endpoint: input.endpoint,
88
+ timeoutMs: input.timeoutMs ?? apiTimeoutMs,
89
+ label: input.label,
90
+ jsonHeaders: false
91
+ });
92
+ }
93
+ return {
94
+ async startQrLogin(input) {
95
+ const payload = await post({
96
+ requestBaseUrl: baseUrl,
97
+ endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(input.botType ?? DEFAULT_BOT_TYPE)}`,
98
+ body: {
99
+ local_token_list: input.localTokenList ?? []
100
+ },
101
+ label: "get_bot_qrcode"
102
+ });
103
+ const qrcode = stringOrUndefined(payload.qrcode);
104
+ const qrcodeUrl = stringOrUndefined(payload.qrcode_img_content);
105
+ if (!qrcode || !qrcodeUrl) {
106
+ throw new Error("get_bot_qrcode did not return qrcode/qrcode_img_content.");
107
+ }
108
+ return { qrcode, qrcodeUrl };
109
+ },
110
+ async checkQrLogin(input) {
111
+ let endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(input.qrcode)}`;
112
+ if (input.verifyCode?.trim()) {
113
+ endpoint += `&verify_code=${encodeURIComponent(input.verifyCode.trim())}`;
114
+ }
115
+ const payload = await get({
116
+ requestBaseUrl: input.baseUrl,
117
+ endpoint,
118
+ timeoutMs: 35_000,
119
+ label: "get_qrcode_status"
120
+ });
121
+ const status = normalizeQrStatus(payload.status);
122
+ return {
123
+ status,
124
+ redirectHost: stringOrUndefined(payload.redirect_host),
125
+ accountId: stringOrUndefined(payload.ilink_bot_id),
126
+ token: stringOrUndefined(payload.bot_token),
127
+ baseUrl: stringOrUndefined(payload.baseurl),
128
+ loginUserId: stringOrUndefined(payload.ilink_user_id),
129
+ raw: payload
130
+ };
131
+ },
132
+ async getUpdates(input) {
133
+ const payload = await post({
134
+ requestBaseUrl: input.account.baseUrl,
135
+ endpoint: "ilink/bot/getupdates",
136
+ token: input.account.token,
137
+ timeoutMs: input.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS,
138
+ label: "getupdates",
139
+ signal: input.signal,
140
+ body: {
141
+ get_updates_buf: input.cursor ?? "",
142
+ base_info: buildBaseInfo()
143
+ }
144
+ });
145
+ assertIlinkOk(payload, "getupdates");
146
+ const cursor = stringOrUndefined(payload.get_updates_buf) ?? input.cursor ?? "";
147
+ const timeoutMs = typeof payload.longpolling_timeout_ms === "number"
148
+ ? payload.longpolling_timeout_ms
149
+ : undefined;
150
+ const messages = Array.isArray(payload.msgs) ? payload.msgs : [];
151
+ return {
152
+ cursor,
153
+ timeoutMs,
154
+ updates: messages.flatMap(parseUpdate)
155
+ };
156
+ },
157
+ async sendText(input) {
158
+ const clientId = `vcm-gateway-${randomUUID()}`;
159
+ const payload = await post({
160
+ requestBaseUrl: input.account.baseUrl,
161
+ endpoint: "ilink/bot/sendmessage",
162
+ token: input.account.token,
163
+ label: "sendmessage",
164
+ body: {
165
+ msg: {
166
+ from_user_id: "",
167
+ to_user_id: input.toUserId,
168
+ client_id: clientId,
169
+ message_type: MESSAGE_TYPE_BOT,
170
+ message_state: MESSAGE_STATE_FINISH,
171
+ item_list: [
172
+ {
173
+ type: MESSAGE_ITEM_TEXT,
174
+ text_item: {
175
+ text: input.text
176
+ }
177
+ }
178
+ ],
179
+ context_token: input.contextToken || undefined
180
+ },
181
+ base_info: buildBaseInfo()
182
+ }
183
+ });
184
+ assertIlinkOk(payload, "sendmessage");
185
+ return clientId;
186
+ }
187
+ };
188
+ }
189
+ function parseUpdate(raw) {
190
+ if (!raw || typeof raw !== "object") {
191
+ return [];
192
+ }
193
+ const message = raw;
194
+ const fromUserId = stringOrUndefined(message.from_user_id);
195
+ if (!fromUserId) {
196
+ return [];
197
+ }
198
+ if (message.message_type === MESSAGE_TYPE_BOT) {
199
+ return [];
200
+ }
201
+ if (message.message_type !== undefined && message.message_type !== MESSAGE_TYPE_USER) {
202
+ return [];
203
+ }
204
+ const text = extractText(message);
205
+ if (!text.trim()) {
206
+ return [];
207
+ }
208
+ const messageId = String(message.message_id ?? message.client_id ?? `${fromUserId}:${String(message.create_time_ms ?? "")}`);
209
+ const createdAt = typeof message.create_time_ms === "number"
210
+ ? new Date(message.create_time_ms).toISOString()
211
+ : undefined;
212
+ return [{
213
+ messageId,
214
+ fromUserId,
215
+ text,
216
+ contextToken: stringOrUndefined(message.context_token),
217
+ createdAt,
218
+ raw: message
219
+ }];
220
+ }
221
+ function extractText(message) {
222
+ const parts = [];
223
+ const items = Array.isArray(message.item_list) ? message.item_list : [];
224
+ for (const item of items) {
225
+ if (!item || typeof item !== "object") {
226
+ continue;
227
+ }
228
+ const candidate = item;
229
+ const textItem = candidate.text_item;
230
+ const voiceItem = candidate.voice_item;
231
+ const text = stringOrUndefined(textItem?.text) ?? stringOrUndefined(voiceItem?.text);
232
+ if (text) {
233
+ parts.push(text);
234
+ }
235
+ }
236
+ return parts.join("\n").trim();
237
+ }
238
+ function assertIlinkOk(payload, label) {
239
+ const ret = typeof payload.ret === "number" ? payload.ret : undefined;
240
+ const errcode = typeof payload.errcode === "number" ? payload.errcode : undefined;
241
+ if ((ret !== undefined && ret !== 0) || (errcode !== undefined && errcode !== 0)) {
242
+ const code = ret ?? errcode;
243
+ const message = stringOrUndefined(payload.errmsg) ?? `${label} failed`;
244
+ if (code === SESSION_EXPIRED_ERRCODE) {
245
+ throw new Error(`iLink session expired: ${message}`);
246
+ }
247
+ throw new Error(`${label} failed ret=${String(ret)} errcode=${String(errcode)} ${message}`);
248
+ }
249
+ }
250
+ function normalizeQrStatus(value) {
251
+ switch (value) {
252
+ case "wait":
253
+ case "scaned":
254
+ case "need_verifycode":
255
+ case "verify_code_blocked":
256
+ case "expired":
257
+ case "scaned_but_redirect":
258
+ case "binded_redirect":
259
+ case "confirmed":
260
+ return value;
261
+ default:
262
+ return "failed";
263
+ }
264
+ }
265
+ function normalizeBaseUrl(input) {
266
+ const trimmed = input.trim();
267
+ if (!trimmed) {
268
+ return DEFAULT_BASE_URL;
269
+ }
270
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
271
+ return trimmed.replace(/\/+$/, "");
272
+ }
273
+ return `https://${trimmed.replace(/\/+$/, "")}`;
274
+ }
275
+ function ensureTrailingSlash(input) {
276
+ return input.endsWith("/") ? input : `${input}/`;
277
+ }
278
+ function buildClientVersion(version) {
279
+ const [major = 0, minor = 0, patch = 0] = version.split(".").map((part) => Number.parseInt(part, 10) || 0);
280
+ return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);
281
+ }
282
+ function randomWechatUin() {
283
+ const value = randomBytes(4).readUInt32BE(0);
284
+ return Buffer.from(String(value), "utf8").toString("base64");
285
+ }
286
+ function combineSignals(first, second) {
287
+ if (!first) {
288
+ return second;
289
+ }
290
+ if (!second) {
291
+ return first;
292
+ }
293
+ const controller = new AbortController();
294
+ const abort = () => controller.abort();
295
+ first.addEventListener("abort", abort, { once: true });
296
+ second.addEventListener("abort", abort, { once: true });
297
+ if (first.aborted || second.aborted) {
298
+ controller.abort();
299
+ }
300
+ return controller.signal;
301
+ }
302
+ function stringOrUndefined(value) {
303
+ return typeof value === "string" ? value : undefined;
304
+ }
@@ -0,0 +1,39 @@
1
+ export function createGatewayAuditLog(deps) {
2
+ const now = deps.now ?? (() => new Date().toISOString());
3
+ return {
4
+ async record(input) {
5
+ await deps.fs.appendText(deps.auditPath, `${JSON.stringify(redactEvent({
6
+ ...input,
7
+ createdAt: now()
8
+ }))}\n`);
9
+ }
10
+ };
11
+ }
12
+ function redactEvent(input) {
13
+ return {
14
+ ...input,
15
+ preview: input.preview ? input.preview.slice(0, 160) : undefined,
16
+ error: input.error ? redactSecrets(input.error).slice(0, 500) : undefined,
17
+ metadata: input.metadata ? redactObject(input.metadata) : undefined
18
+ };
19
+ }
20
+ function redactObject(input) {
21
+ const out = {};
22
+ for (const [key, value] of Object.entries(input)) {
23
+ if (/token|authorization|qrcode|secret|apiKey/i.test(key)) {
24
+ out[key] = "[redacted]";
25
+ }
26
+ else if (typeof value === "string") {
27
+ out[key] = redactSecrets(value).slice(0, 500);
28
+ }
29
+ else {
30
+ out[key] = value;
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+ function redactSecrets(input) {
36
+ return input
37
+ .replace(/Bearer\s+[A-Za-z0-9._-]+/g, "Bearer [redacted]")
38
+ .replace(/bot_token["'\s:=]+[A-Za-z0-9._-]+/gi, "bot_token=[redacted]");
39
+ }
@@ -0,0 +1,77 @@
1
+ export function parseGatewayCommand(input) {
2
+ const text = input.trim();
3
+ if (!text.startsWith("/")) {
4
+ return { kind: "plain", text };
5
+ }
6
+ const [rawName = "", ...rest] = splitArgs(text);
7
+ const name = rawName.toLowerCase();
8
+ switch (name) {
9
+ case "/help":
10
+ return { kind: "help" };
11
+ case "/status":
12
+ return { kind: "status" };
13
+ case "/projects":
14
+ return { kind: "projects" };
15
+ case "/use-project":
16
+ return rest[0] ? { kind: "use-project", selector: rest.join(" ") } : { kind: "unknown", name };
17
+ case "/pull-current":
18
+ return { kind: "pull-current" };
19
+ case "/tasks":
20
+ return { kind: "tasks" };
21
+ case "/use-task":
22
+ return rest[0] ? { kind: "use-task", selector: rest[0] } : { kind: "unknown", name };
23
+ case "/create-task": {
24
+ const [taskSlug, ...titleParts] = rest;
25
+ return taskSlug
26
+ ? { kind: "create-task", taskSlug, title: titleParts.join(" ").trim() || undefined }
27
+ : { kind: "unknown", name };
28
+ }
29
+ case "/close-task":
30
+ if (rest[0]?.toLowerCase() === "confirm" && rest[1]) {
31
+ return { kind: "close-task-confirm", taskSlug: rest[1] };
32
+ }
33
+ return { kind: "close-task" };
34
+ case "/translate":
35
+ if (rest[0]?.toLowerCase() === "on") {
36
+ return { kind: "translate", enabled: true };
37
+ }
38
+ if (rest[0]?.toLowerCase() === "off") {
39
+ return { kind: "translate", enabled: false };
40
+ }
41
+ return { kind: "unknown", name };
42
+ default:
43
+ return { kind: "unknown", name };
44
+ }
45
+ }
46
+ function splitArgs(input) {
47
+ const out = [];
48
+ let current = "";
49
+ let quote = null;
50
+ for (const char of input) {
51
+ if (quote) {
52
+ if (char === quote) {
53
+ quote = null;
54
+ }
55
+ else {
56
+ current += char;
57
+ }
58
+ continue;
59
+ }
60
+ if (char === "\"" || char === "'") {
61
+ quote = char;
62
+ continue;
63
+ }
64
+ if (/\s/.test(char)) {
65
+ if (current) {
66
+ out.push(current);
67
+ current = "";
68
+ }
69
+ continue;
70
+ }
71
+ current += char;
72
+ }
73
+ if (current) {
74
+ out.push(current);
75
+ }
76
+ return out;
77
+ }