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.
@@ -0,0 +1,848 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { ROLE_DEFINITIONS } from "../../shared/constants.js";
3
+ import { VcmError } from "../errors.js";
4
+ import { submitTerminalInput } from "../runtime/terminal-submit.js";
5
+ import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
6
+ import { parseAssistantContent, resolveExistingClaudeTranscriptPath } from "../services/claude-transcript-service.js";
7
+ import { parseGatewayCommand } from "./gateway-command-parser.js";
8
+ const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
9
+ const QR_LOGIN_TTL_MS = 8 * 60 * 1000;
10
+ const CLOSE_CONFIRM_TTL_MS = 10 * 60 * 1000;
11
+ const POLL_ERROR_BACKOFF_MS = 2_000;
12
+ const POLL_LONG_BACKOFF_MS = 30_000;
13
+ const MAX_FAILURES_BEFORE_LONG_BACKOFF = 3;
14
+ const DEFAULT_POLL_TIMEOUT_MS = 35_000;
15
+ export function createGatewayService(deps) {
16
+ const now = deps.now ?? (() => new Date().toISOString());
17
+ let pollAbort = null;
18
+ let pollLoopPromise = null;
19
+ let qrLogin = null;
20
+ function isRunning() {
21
+ return Boolean(pollAbort && !pollAbort.signal.aborted);
22
+ }
23
+ async function ensurePolling() {
24
+ const settings = await deps.settings.loadSettings();
25
+ if (!settings.enabled || !settings.binding.token || isRunning()) {
26
+ return;
27
+ }
28
+ pollAbort = new AbortController();
29
+ pollLoopPromise = pollLoop(pollAbort.signal).finally(() => {
30
+ pollAbort = null;
31
+ pollLoopPromise = null;
32
+ });
33
+ }
34
+ async function stopPolling() {
35
+ if (!pollAbort) {
36
+ return;
37
+ }
38
+ pollAbort.abort();
39
+ await pollLoopPromise?.catch(() => undefined);
40
+ }
41
+ function toAccount(settings) {
42
+ if (!settings.binding.token) {
43
+ return undefined;
44
+ }
45
+ return {
46
+ accountId: settings.binding.accountId,
47
+ baseUrl: settings.binding.baseUrl || DEFAULT_BASE_URL,
48
+ token: settings.binding.token
49
+ };
50
+ }
51
+ async function pollLoop(signal) {
52
+ let consecutiveFailures = 0;
53
+ let timeoutMs = DEFAULT_POLL_TIMEOUT_MS;
54
+ await savePollStatus("running");
55
+ while (!signal.aborted) {
56
+ const settings = await deps.settings.loadSettings();
57
+ const account = toAccount(settings);
58
+ if (!settings.enabled || !account) {
59
+ await savePollStatus("idle");
60
+ return;
61
+ }
62
+ try {
63
+ const result = await deps.channel.getUpdates({
64
+ account,
65
+ cursor: settings.binding.getUpdatesBuf,
66
+ timeoutMs,
67
+ signal
68
+ });
69
+ consecutiveFailures = 0;
70
+ timeoutMs = result.timeoutMs ?? timeoutMs;
71
+ const nextSettings = await deps.settings.loadSettings();
72
+ await deps.settings.saveSettings({
73
+ ...nextSettings,
74
+ binding: {
75
+ ...nextSettings.binding,
76
+ getUpdatesBuf: result.cursor
77
+ },
78
+ lastPollStatus: {
79
+ state: "running",
80
+ checkedAt: now()
81
+ },
82
+ updatedAt: now()
83
+ });
84
+ for (const update of result.updates) {
85
+ if (signal.aborted) {
86
+ return;
87
+ }
88
+ await handleInbound(update).catch(() => undefined);
89
+ }
90
+ }
91
+ catch (error) {
92
+ if (signal.aborted) {
93
+ return;
94
+ }
95
+ consecutiveFailures += 1;
96
+ const message = errorMessage(error);
97
+ const expired = message.toLowerCase().includes("expired");
98
+ const settingsAfterError = await deps.settings.loadSettings();
99
+ await deps.settings.saveSettings({
100
+ ...settingsAfterError,
101
+ enabled: expired ? false : settingsAfterError.enabled,
102
+ lastPollStatus: {
103
+ state: expired ? "expired" : "error",
104
+ checkedAt: now(),
105
+ error: message
106
+ },
107
+ updatedAt: now()
108
+ });
109
+ await deps.audit.record({
110
+ type: "gateway.poll",
111
+ result: "error",
112
+ error: message
113
+ });
114
+ if (expired) {
115
+ return;
116
+ }
117
+ await sleep(consecutiveFailures >= MAX_FAILURES_BEFORE_LONG_BACKOFF ? POLL_LONG_BACKOFF_MS : POLL_ERROR_BACKOFF_MS, signal);
118
+ if (consecutiveFailures >= MAX_FAILURES_BEFORE_LONG_BACKOFF) {
119
+ consecutiveFailures = 0;
120
+ }
121
+ }
122
+ }
123
+ }
124
+ async function savePollStatus(state, error) {
125
+ const settings = await deps.settings.loadSettings();
126
+ await deps.settings.saveSettings({
127
+ ...settings,
128
+ lastPollStatus: {
129
+ state,
130
+ checkedAt: now(),
131
+ error
132
+ },
133
+ updatedAt: now()
134
+ });
135
+ }
136
+ async function handleInbound(update) {
137
+ let settings = await deps.settings.loadSettings();
138
+ if (settings.dedupe.recentInboundMessageIds.includes(update.messageId)) {
139
+ await deps.audit.record({
140
+ type: "gateway.inbound",
141
+ result: "ignored",
142
+ messageId: update.messageId,
143
+ userId: update.fromUserId,
144
+ preview: update.text
145
+ });
146
+ return;
147
+ }
148
+ settings = await deps.settings.saveSettings({
149
+ ...settings,
150
+ binding: {
151
+ ...settings.binding,
152
+ boundUserId: settings.binding.boundUserId ?? update.fromUserId,
153
+ contextTokens: update.contextToken
154
+ ? {
155
+ ...settings.binding.contextTokens,
156
+ [update.fromUserId]: update.contextToken
157
+ }
158
+ : settings.binding.contextTokens
159
+ },
160
+ dedupe: {
161
+ recentInboundMessageIds: [...settings.dedupe.recentInboundMessageIds, update.messageId].slice(-1000)
162
+ },
163
+ updatedAt: now()
164
+ });
165
+ if (settings.binding.boundUserId !== update.fromUserId) {
166
+ await reply(settings, update.fromUserId, "This VCM gateway is already bound to another Weixin DM.");
167
+ await recordMessageStatus("inbound", "ignored", update.text, "unbound user");
168
+ return;
169
+ }
170
+ const command = parseGatewayCommand(update.text);
171
+ try {
172
+ const output = await executeCommand(command);
173
+ await reply(await deps.settings.loadSettings(), update.fromUserId, output);
174
+ await recordMessageStatus("inbound", "ok", update.text, undefined, command.kind);
175
+ await deps.audit.record({
176
+ type: "gateway.command",
177
+ result: "ok",
178
+ messageId: update.messageId,
179
+ userId: update.fromUserId,
180
+ command: command.kind,
181
+ preview: update.text
182
+ });
183
+ }
184
+ catch (error) {
185
+ const message = errorMessage(error);
186
+ await reply(await deps.settings.loadSettings(), update.fromUserId, `Error: ${message}`);
187
+ await recordMessageStatus("inbound", "error", update.text, message, command.kind);
188
+ await deps.audit.record({
189
+ type: "gateway.command",
190
+ result: "error",
191
+ messageId: update.messageId,
192
+ userId: update.fromUserId,
193
+ command: command.kind,
194
+ preview: update.text,
195
+ error: message
196
+ });
197
+ }
198
+ }
199
+ async function executeCommand(command) {
200
+ switch (command.kind) {
201
+ case "help":
202
+ return helpText();
203
+ case "status":
204
+ return statusText(await deps.settings.loadSettings());
205
+ case "projects":
206
+ return projectsText(await listProjectOptions());
207
+ case "use-project":
208
+ return useProject(command.selector);
209
+ case "pull-current":
210
+ return pullCurrent();
211
+ case "tasks":
212
+ return tasksText(await ensureProject());
213
+ case "use-task":
214
+ return useTask(command.selector);
215
+ case "create-task":
216
+ return createTask(command.taskSlug, command.title);
217
+ case "close-task":
218
+ return closeTaskPrompt();
219
+ case "close-task-confirm":
220
+ return closeTaskConfirm(command.taskSlug);
221
+ case "translate":
222
+ return setGatewayTranslation(command.enabled);
223
+ case "plain":
224
+ return sendPlainTextToPm(command.text);
225
+ case "unknown":
226
+ return `Unknown command: ${command.name}. Send /help for available commands.`;
227
+ }
228
+ }
229
+ async function ensureProject() {
230
+ const settings = await deps.settings.loadSettings();
231
+ let project = await deps.projectService.getCurrentProject();
232
+ if (!project && settings.currentProjectId) {
233
+ project = await deps.projectService.connectProject({ repoPath: settings.currentProjectId });
234
+ }
235
+ if (!project) {
236
+ throw new VcmError({
237
+ code: "PROJECT_NOT_CONNECTED",
238
+ message: "No project is selected. Use /projects and /use-project first.",
239
+ statusCode: 409
240
+ });
241
+ }
242
+ if (settings.currentProjectId !== project.repoRoot) {
243
+ await deps.settings.saveSettings({
244
+ ...settings,
245
+ currentProjectId: project.repoRoot,
246
+ updatedAt: now()
247
+ });
248
+ }
249
+ return project;
250
+ }
251
+ async function syncDesktopContext(settings) {
252
+ const project = await deps.projectService.getCurrentProject();
253
+ if (!project) {
254
+ return settings;
255
+ }
256
+ const tasks = await deps.taskService.listTasks(project.repoRoot);
257
+ const usableTasks = tasks.filter((task) => task.cleanupStatus !== "cleaned");
258
+ const selectedTask = settings.currentTaskSlug
259
+ ? usableTasks.find((task) => task.taskSlug === settings.currentTaskSlug) ?? usableTasks[0]
260
+ : usableTasks[0];
261
+ const nextTaskSlug = selectedTask?.taskSlug ?? null;
262
+ if (settings.currentProjectId === project.repoRoot && settings.currentTaskSlug === nextTaskSlug) {
263
+ return settings;
264
+ }
265
+ return deps.settings.saveSettings({
266
+ ...settings,
267
+ currentProjectId: project.repoRoot,
268
+ currentTaskSlug: nextTaskSlug,
269
+ updatedAt: now()
270
+ });
271
+ }
272
+ async function listProjectOptions() {
273
+ const project = await deps.projectService.getCurrentProject();
274
+ const recent = await deps.projectService.getRecentRepositoryPaths();
275
+ const paths = [project?.repoRoot, ...recent].filter((value) => Boolean(value));
276
+ return [...new Set(paths)];
277
+ }
278
+ async function useProject(selector) {
279
+ const options = await listProjectOptions();
280
+ const index = Number.parseInt(selector, 10);
281
+ const repoPath = Number.isFinite(index) && String(index) === selector.trim()
282
+ ? options[index - 1]
283
+ : selector.trim();
284
+ if (!repoPath) {
285
+ throw new VcmError({
286
+ code: "PROJECT_SELECTION_INVALID",
287
+ message: `Project not found: ${selector}`,
288
+ statusCode: 404
289
+ });
290
+ }
291
+ const project = await deps.projectService.connectProject({ repoPath });
292
+ const settings = await deps.settings.loadSettings();
293
+ await deps.settings.saveSettings({
294
+ ...settings,
295
+ currentProjectId: project.repoRoot,
296
+ currentTaskSlug: null,
297
+ updatedAt: now()
298
+ });
299
+ return `Selected project:\n${project.repoRoot}\nbranch: ${project.branch}\ncommit: ${project.shortHeadCommit ?? project.headCommit ?? "unknown"}`;
300
+ }
301
+ async function pullCurrent() {
302
+ const project = await ensureProject();
303
+ const settings = await syncDesktopContext(await deps.settings.loadSettings());
304
+ if (settings.currentTaskSlug) {
305
+ const task = await deps.taskService.loadTask(project.repoRoot, settings.currentTaskSlug);
306
+ if (!task.worktreePath && task.cleanupStatus !== "cleaned") {
307
+ throw new VcmError({
308
+ code: "GATEWAY_PULL_BLOCKED_BY_INLINE_TASK",
309
+ message: `Inline task "${task.taskSlug}" uses the base repository.`,
310
+ statusCode: 409
311
+ });
312
+ }
313
+ }
314
+ const pulled = await deps.projectService.pullCurrentProject();
315
+ return [
316
+ "Connected repository updated.",
317
+ `branch: ${pulled.branch}`,
318
+ `remote: ${pulled.upstreamBranch ?? "no upstream"}`,
319
+ `status: ${formatAheadBehind(pulled)}`,
320
+ `commit: ${pulled.shortHeadCommit ?? pulled.headCommit ?? "unknown"}`
321
+ ].join("\n");
322
+ }
323
+ async function tasksText(project) {
324
+ const tasks = await deps.taskService.listTasks(project.repoRoot);
325
+ if (tasks.length === 0) {
326
+ return "No tasks. Use /create-task <task-slug> [title] to create one.";
327
+ }
328
+ return tasks.map((task, index) => `${index + 1}. ${task.taskSlug} [${task.status}] ${task.worktreePath ? "worktree" : "inline"}`).join("\n");
329
+ }
330
+ async function useTask(selector) {
331
+ const project = await ensureProject();
332
+ const tasks = await deps.taskService.listTasks(project.repoRoot);
333
+ const index = Number.parseInt(selector, 10);
334
+ const task = Number.isFinite(index) && String(index) === selector.trim()
335
+ ? tasks[index - 1]
336
+ : tasks.find((candidate) => candidate.taskSlug === selector.trim());
337
+ if (!task) {
338
+ throw new VcmError({
339
+ code: "TASK_NOT_FOUND",
340
+ message: `Task not found: ${selector}`,
341
+ statusCode: 404
342
+ });
343
+ }
344
+ const settings = await deps.settings.loadSettings();
345
+ await deps.settings.saveSettings({
346
+ ...settings,
347
+ currentTaskSlug: task.taskSlug,
348
+ updatedAt: now()
349
+ });
350
+ return `Selected task: ${task.taskSlug}\nstatus: ${task.status}\nbranch: ${task.branch}`;
351
+ }
352
+ async function createTask(taskSlug, title) {
353
+ const project = await ensureProject();
354
+ const task = await deps.taskService.createTask(project.repoRoot, {
355
+ taskSlug,
356
+ title,
357
+ createWorktree: true
358
+ });
359
+ const config = await deps.projectService.loadConfig(project.repoRoot);
360
+ const taskRepoRoot = getTaskRuntimeRepoRoot(task);
361
+ const preferences = await deps.appSettings.getPreferences();
362
+ const template = preferences.launchTemplate;
363
+ await deps.messageService.updateOrchestrationState({
364
+ repoRoot: project.repoRoot,
365
+ stateRepoRoot: taskRepoRoot,
366
+ stateRoot: config.stateRoot,
367
+ taskSlug: task.taskSlug,
368
+ mode: template.autoOrchestration ? "auto" : "manual"
369
+ });
370
+ const startedRoles = [];
371
+ for (const definition of ROLE_DEFINITIONS) {
372
+ const roleTemplate = template.roles[definition.name];
373
+ try {
374
+ await deps.sessionService.startRoleSession(project.repoRoot, task.taskSlug, definition.name, {
375
+ cols: 100,
376
+ rows: 28,
377
+ permissionMode: roleTemplate.permissionMode,
378
+ model: roleTemplate.model
379
+ });
380
+ startedRoles.push(definition.name);
381
+ }
382
+ catch (error) {
383
+ const settings = await deps.settings.loadSettings();
384
+ await deps.settings.saveSettings({
385
+ ...settings,
386
+ currentProjectId: project.repoRoot,
387
+ currentTaskSlug: task.taskSlug,
388
+ translationEnabled: template.translationEnabled,
389
+ updatedAt: now()
390
+ });
391
+ throw new VcmError({
392
+ code: "GATEWAY_TASK_PARTIAL_START",
393
+ message: `Task was created, but ${definition.name} failed to start.`,
394
+ statusCode: 409,
395
+ hint: errorMessage(error)
396
+ });
397
+ }
398
+ }
399
+ const settings = await deps.settings.loadSettings();
400
+ await deps.settings.saveSettings({
401
+ ...settings,
402
+ currentProjectId: project.repoRoot,
403
+ currentTaskSlug: task.taskSlug,
404
+ translationEnabled: template.translationEnabled,
405
+ updatedAt: now()
406
+ });
407
+ return [
408
+ `Task created and initialized: ${task.taskSlug}`,
409
+ `branch: ${task.branch}`,
410
+ `worktree: ${task.worktreePath ?? task.repoRoot}`,
411
+ `orchestration: ${template.autoOrchestration ? "auto" : "manual"}`,
412
+ `translation: ${template.translationEnabled ? "on" : "off"}`,
413
+ `sessions: ${startedRoles.join(", ")}`
414
+ ].join("\n");
415
+ }
416
+ async function closeTaskPrompt() {
417
+ const project = await ensureProject();
418
+ const settings = await syncDesktopContext(await deps.settings.loadSettings());
419
+ const taskSlug = settings.currentTaskSlug;
420
+ if (!taskSlug) {
421
+ throw new VcmError({
422
+ code: "TASK_NOT_SELECTED",
423
+ message: "No task is selected. Use /tasks and /use-task first.",
424
+ statusCode: 409
425
+ });
426
+ }
427
+ await deps.taskService.loadTask(project.repoRoot, taskSlug);
428
+ const createdAt = now();
429
+ const expiresAt = new Date(Date.parse(createdAt) + CLOSE_CONFIRM_TTL_MS).toISOString();
430
+ await deps.settings.saveSettings({
431
+ ...settings,
432
+ pendingConfirmations: {
433
+ ...settings.pendingConfirmations,
434
+ closeTask: {
435
+ taskSlug,
436
+ createdAt,
437
+ expiresAt
438
+ }
439
+ },
440
+ updatedAt: now()
441
+ });
442
+ return [
443
+ `Close task "${taskSlug}"?`,
444
+ "This stops VCM-managed role sessions and removes the task worktree/branch when owned by the task.",
445
+ `Confirm with: /close-task confirm ${taskSlug}`
446
+ ].join("\n");
447
+ }
448
+ async function closeTaskConfirm(taskSlug) {
449
+ const project = await ensureProject();
450
+ const settings = await deps.settings.loadSettings();
451
+ const confirmation = settings.pendingConfirmations.closeTask;
452
+ if (!confirmation || confirmation.taskSlug !== taskSlug || Date.parse(confirmation.expiresAt) < Date.now()) {
453
+ throw new VcmError({
454
+ code: "GATEWAY_CLOSE_CONFIRMATION_INVALID",
455
+ message: `No active close confirmation for ${taskSlug}. Run /close-task first.`,
456
+ statusCode: 409
457
+ });
458
+ }
459
+ if (settings.currentTaskSlug !== taskSlug) {
460
+ throw new VcmError({
461
+ code: "GATEWAY_CLOSE_TASK_MISMATCH",
462
+ message: `Current task is ${settings.currentTaskSlug ?? "not selected"}.`,
463
+ statusCode: 409,
464
+ hint: settings.currentTaskSlug ? `Use /close-task confirm ${settings.currentTaskSlug}` : undefined
465
+ });
466
+ }
467
+ const task = await deps.taskService.loadTask(project.repoRoot, taskSlug);
468
+ await stopRunningRoleSessions(project.repoRoot, taskSlug);
469
+ await deps.translationService.stopTask(getTaskRuntimeRepoRoot(task), taskSlug, { clearCache: true });
470
+ deps.roundService.stopTask(taskSlug);
471
+ const result = await deps.taskService.cleanupTask(project.repoRoot, taskSlug, {
472
+ force: true,
473
+ deleteBranch: Boolean(task.worktreePath),
474
+ forceDeleteBranch: true
475
+ });
476
+ await deps.settings.saveSettings({
477
+ ...settings,
478
+ currentTaskSlug: null,
479
+ pendingConfirmations: {
480
+ ...settings.pendingConfirmations,
481
+ closeTask: null
482
+ },
483
+ updatedAt: now()
484
+ });
485
+ return [
486
+ `Closed task: ${result.taskSlug}`,
487
+ result.removedWorktreePath ? `removed worktree: ${result.removedWorktreePath}` : "removed worktree: none",
488
+ result.deletedBranch ? `deleted branch: ${result.deletedBranch}` : "deleted branch: none",
489
+ `removed state paths: ${result.removedStatePaths.length}`
490
+ ].join("\n");
491
+ }
492
+ async function stopRunningRoleSessions(repoRoot, taskSlug) {
493
+ const sessions = await deps.sessionService.listRoleSessions(repoRoot, taskSlug);
494
+ for (const session of sessions) {
495
+ if (session.status === "running") {
496
+ await deps.sessionService.stopRoleSession(repoRoot, taskSlug, session.role);
497
+ }
498
+ }
499
+ }
500
+ async function setGatewayTranslation(enabled) {
501
+ const settings = await deps.settings.updateSettings({ translationEnabled: enabled });
502
+ return `Gateway translation ${settings.translationEnabled ? "on" : "off"}.`;
503
+ }
504
+ async function sendPlainTextToPm(text) {
505
+ if (!text.trim()) {
506
+ return "Empty message ignored.";
507
+ }
508
+ const project = await ensureProject();
509
+ const settings = await syncDesktopContext(await deps.settings.loadSettings());
510
+ if (!settings.currentTaskSlug) {
511
+ throw new VcmError({
512
+ code: "TASK_NOT_SELECTED",
513
+ message: "No task is selected. Use /tasks and /use-task first.",
514
+ statusCode: 409
515
+ });
516
+ }
517
+ const task = await deps.taskService.loadTask(project.repoRoot, settings.currentTaskSlug);
518
+ const session = await deps.sessionService.getRoleSession(project.repoRoot, task.taskSlug, "project-manager");
519
+ if (!session || session.status !== "running") {
520
+ throw new VcmError({
521
+ code: "PM_SESSION_NOT_RUNNING",
522
+ message: "The current task's PM session is not running.",
523
+ statusCode: 409
524
+ });
525
+ }
526
+ if (session.activityStatus === "running") {
527
+ return "PM is still working on the current turn. Please wait and send again later.";
528
+ }
529
+ const englishText = settings.translationEnabled
530
+ ? (await deps.translationService.translateUserInput({
531
+ repoRoot: project.repoRoot,
532
+ taskRepoRoot: getTaskRuntimeRepoRoot(task),
533
+ taskSlug: task.taskSlug,
534
+ role: "project-manager",
535
+ text,
536
+ useContext: false,
537
+ send: false
538
+ })).englishPreview
539
+ : text;
540
+ await submitTerminalInput(deps.runtime, session.id, `[VCM Gateway]\n${englishText}`);
541
+ return "Sent to PM.";
542
+ }
543
+ async function reply(settings, userId, text) {
544
+ const account = toAccount(settings);
545
+ if (!account) {
546
+ return;
547
+ }
548
+ const contextToken = settings.binding.contextTokens[userId];
549
+ await deps.channel.sendText({
550
+ account,
551
+ toUserId: userId,
552
+ contextToken,
553
+ text
554
+ });
555
+ await recordMessageStatus("outbound", "ok", text);
556
+ }
557
+ async function recordMessageStatus(direction, result, preview, error, command) {
558
+ const settings = await deps.settings.loadSettings();
559
+ await deps.settings.saveSettings({
560
+ ...settings,
561
+ lastMessageStatus: {
562
+ checkedAt: now(),
563
+ direction,
564
+ result,
565
+ preview: preview.slice(0, 160),
566
+ error,
567
+ command
568
+ },
569
+ updatedAt: now()
570
+ });
571
+ }
572
+ async function statusText(settings) {
573
+ const synced = await syncDesktopContext(settings);
574
+ const project = await deps.projectService.getCurrentProject();
575
+ return [
576
+ `Gateway: ${synced.enabled ? "on" : "off"}${isRunning() ? " / polling" : ""}`,
577
+ `Binding: ${synced.binding.boundUserId ? "bound" : "not bound"}`,
578
+ `Translation: ${synced.translationEnabled ? "on" : "off"}`,
579
+ `Project: ${project?.repoRoot ?? synced.currentProjectId ?? "none"}`,
580
+ `Task: ${synced.currentTaskSlug ?? "none"}`,
581
+ `Last poll: ${synced.lastPollStatus.state}${synced.lastPollStatus.error ? ` (${synced.lastPollStatus.error})` : ""}`
582
+ ].join("\n");
583
+ }
584
+ return {
585
+ async start() {
586
+ await ensurePolling();
587
+ },
588
+ stop() {
589
+ void stopPolling();
590
+ },
591
+ async getStatus() {
592
+ return deps.settings.expose(await syncDesktopContext(await deps.settings.loadSettings()), isRunning());
593
+ },
594
+ async updateSettings(input) {
595
+ let settings = await deps.settings.updateSettings(input);
596
+ if (settings.enabled) {
597
+ settings = await syncDesktopContext(settings);
598
+ }
599
+ if (settings.enabled) {
600
+ await ensurePolling();
601
+ }
602
+ else {
603
+ await stopPolling();
604
+ }
605
+ return deps.settings.expose(settings, isRunning());
606
+ },
607
+ async resetBinding() {
608
+ await stopPolling();
609
+ const settings = await deps.settings.resetBinding();
610
+ return deps.settings.expose(settings, isRunning());
611
+ },
612
+ async startQrLogin() {
613
+ const settings = await deps.settings.loadSettings();
614
+ const login = await deps.channel.startQrLogin({
615
+ localTokenList: settings.binding.token ? [settings.binding.token] : []
616
+ });
617
+ qrLogin = {
618
+ qrcode: login.qrcode,
619
+ qrcodeUrl: login.qrcodeUrl,
620
+ baseUrl: settings.binding.baseUrl || DEFAULT_BASE_URL,
621
+ expiresAt: new Date(Date.now() + QR_LOGIN_TTL_MS).toISOString()
622
+ };
623
+ return {
624
+ status: "wait",
625
+ qrcode: login.qrcode,
626
+ qrcodeUrl: login.qrcodeUrl,
627
+ expiresAt: qrLogin.expiresAt
628
+ };
629
+ },
630
+ async checkQrLogin(input = {}) {
631
+ if (!qrLogin || Date.parse(qrLogin.expiresAt) < Date.now()) {
632
+ return {
633
+ status: "expired",
634
+ message: "QR login expired. Start a new QR login."
635
+ };
636
+ }
637
+ const result = await deps.channel.checkQrLogin({
638
+ baseUrl: qrLogin.baseUrl,
639
+ qrcode: qrLogin.qrcode,
640
+ verifyCode: input.verifyCode
641
+ });
642
+ if (result.status === "scaned_but_redirect" && result.redirectHost) {
643
+ qrLogin = {
644
+ ...qrLogin,
645
+ baseUrl: normalizeBaseUrl(result.redirectHost)
646
+ };
647
+ }
648
+ if (result.status === "confirmed" || result.status === "binded_redirect") {
649
+ const settings = await deps.settings.loadSettings();
650
+ const token = result.token ?? settings.binding.token;
651
+ if (token) {
652
+ await deps.settings.saveSettings({
653
+ ...settings,
654
+ binding: {
655
+ ...settings.binding,
656
+ accountId: result.accountId ?? settings.binding.accountId,
657
+ baseUrl: normalizeBaseUrl(result.baseUrl ?? settings.binding.baseUrl),
658
+ loginUserId: result.loginUserId ?? settings.binding.loginUserId,
659
+ boundUserId: result.loginUserId ?? settings.binding.boundUserId,
660
+ token
661
+ },
662
+ updatedAt: now()
663
+ });
664
+ qrLogin = null;
665
+ }
666
+ }
667
+ return {
668
+ status: result.status,
669
+ qrcodeUrl: qrLogin?.qrcodeUrl,
670
+ accountId: result.accountId,
671
+ boundUserId: result.loginUserId,
672
+ loginUserId: result.loginUserId
673
+ };
674
+ },
675
+ async handlePmStop(input) {
676
+ if (input.session.role !== "project-manager") {
677
+ return;
678
+ }
679
+ const settings = await deps.settings.loadSettings();
680
+ const account = toAccount(settings);
681
+ const boundUserId = settings.binding.boundUserId;
682
+ if (!settings.enabled || !account || !boundUserId) {
683
+ return;
684
+ }
685
+ const transcriptPath = resolveExistingClaudeTranscriptPath(input.session);
686
+ if (!transcriptPath) {
687
+ return;
688
+ }
689
+ const cursorKey = `${input.taskSlug}:project-manager:${input.session.claudeSessionId}`;
690
+ const cursor = settings.pushCursors[cursorKey];
691
+ const events = await readTranscriptTextEvents(transcriptPath);
692
+ const nextEvents = selectEventsAfterCursor(events, cursor?.lastTranscriptEventId);
693
+ if (nextEvents.length === 0) {
694
+ return;
695
+ }
696
+ const text = nextEvents.map((event) => event.text).join("\n\n").trim();
697
+ if (!text) {
698
+ return;
699
+ }
700
+ let output = text;
701
+ if (settings.translationEnabled) {
702
+ try {
703
+ output = await deps.translationService.translateGatewayOutput({
704
+ repoRoot: input.repoRoot,
705
+ taskSlug: input.taskSlug,
706
+ role: "project-manager",
707
+ text
708
+ });
709
+ }
710
+ catch {
711
+ output = `${text}\n\n[Translation failed; original PM reply shown.]`;
712
+ }
713
+ }
714
+ await deps.channel.sendText({
715
+ account,
716
+ toUserId: boundUserId,
717
+ contextToken: settings.binding.contextTokens[boundUserId],
718
+ text: output
719
+ });
720
+ const lastEvent = nextEvents.at(-1);
721
+ const current = await deps.settings.loadSettings();
722
+ await deps.settings.saveSettings({
723
+ ...current,
724
+ pushCursors: {
725
+ ...current.pushCursors,
726
+ [cursorKey]: {
727
+ lastTranscriptEventId: lastEvent?.id ?? null,
728
+ lastTranscriptTimestamp: lastEvent?.timestamp ?? null
729
+ }
730
+ },
731
+ lastMessageStatus: {
732
+ checkedAt: now(),
733
+ direction: "outbound",
734
+ result: "ok",
735
+ command: "pm-stop",
736
+ preview: output.slice(0, 160)
737
+ },
738
+ updatedAt: now()
739
+ });
740
+ await deps.audit.record({
741
+ type: "gateway.pm_push",
742
+ result: "ok",
743
+ command: "pm-stop",
744
+ preview: output
745
+ });
746
+ }
747
+ };
748
+ }
749
+ async function readTranscriptTextEvents(transcriptPath) {
750
+ const raw = await readFile(transcriptPath, "utf8");
751
+ const events = [];
752
+ for (const line of raw.split("\n")) {
753
+ if (!line.trim()) {
754
+ continue;
755
+ }
756
+ for (const event of parseAssistantContent(line)) {
757
+ if (event.kind === "text") {
758
+ events.push({
759
+ id: event.id,
760
+ timestamp: event.timestamp,
761
+ text: event.text
762
+ });
763
+ }
764
+ }
765
+ }
766
+ return events;
767
+ }
768
+ function selectEventsAfterCursor(events, cursorId) {
769
+ if (events.length === 0) {
770
+ return [];
771
+ }
772
+ if (!cursorId) {
773
+ return [events[events.length - 1]];
774
+ }
775
+ const index = events.findIndex((event) => event.id === cursorId);
776
+ if (index < 0) {
777
+ return [events[events.length - 1]];
778
+ }
779
+ return events.slice(index + 1);
780
+ }
781
+ function projectsText(projects) {
782
+ if (projects.length === 0) {
783
+ return "No recent projects. Connect a repository from desktop VCM first.";
784
+ }
785
+ return projects.map((repoPath, index) => `${index + 1}. ${repoPath}`).join("\n");
786
+ }
787
+ function helpText() {
788
+ return [
789
+ "VCM Gateway commands:",
790
+ "/status",
791
+ "/projects",
792
+ "/use-project <index-or-path>",
793
+ "/pull-current",
794
+ "/tasks",
795
+ "/use-task <index-or-task-slug>",
796
+ "/create-task <task-slug> [title]",
797
+ "/close-task",
798
+ "/close-task confirm <task-slug>",
799
+ "/translate on",
800
+ "/translate off"
801
+ ].join("\n");
802
+ }
803
+ function formatAheadBehind(project) {
804
+ if (!project.upstreamBranch) {
805
+ return "no upstream";
806
+ }
807
+ const ahead = project.ahead ?? 0;
808
+ const behind = project.behind ?? 0;
809
+ if (ahead === 0 && behind === 0) {
810
+ return "up to date";
811
+ }
812
+ if (ahead > 0 && behind > 0) {
813
+ return `ahead ${ahead}, behind ${behind}`;
814
+ }
815
+ return ahead > 0 ? `ahead ${ahead}` : `behind ${behind}`;
816
+ }
817
+ function sleep(ms, signal) {
818
+ return new Promise((resolve) => {
819
+ if (signal.aborted) {
820
+ resolve();
821
+ return;
822
+ }
823
+ const timeout = setTimeout(resolve, ms);
824
+ signal.addEventListener("abort", () => {
825
+ clearTimeout(timeout);
826
+ resolve();
827
+ }, { once: true });
828
+ });
829
+ }
830
+ function normalizeBaseUrl(input) {
831
+ const trimmed = input.trim();
832
+ if (!trimmed) {
833
+ return DEFAULT_BASE_URL;
834
+ }
835
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
836
+ return trimmed.replace(/\/+$/, "");
837
+ }
838
+ return `https://${trimmed.replace(/\/+$/, "")}`;
839
+ }
840
+ function errorMessage(error) {
841
+ if (error instanceof VcmError) {
842
+ return error.hint ? `${error.message} ${error.hint}` : error.message;
843
+ }
844
+ if (error instanceof Error) {
845
+ return error.message;
846
+ }
847
+ return String(error);
848
+ }