vibe-coding-master 0.2.8 → 0.2.10

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
@@ -210,7 +210,7 @@ The left sidebar is intentionally compact and collapsible:
210
210
  - `Repository Path`: path input on one row; `Recent` and `Connect` on the next row.
211
211
  - `Connected Repository`: connected base repo path, branch, upstream/ahead-behind status, commit hash, working tree state, and a `Pull` button.
212
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.
213
+ - `Gateway`: Weixin iLink binding, Gateway on/off, Gateway translation, and QR login.
214
214
  - `VCM Harness`: fixed-install status, bootstrap completion checks, and the bootstrap terminal when one is running.
215
215
  - `New Task`: one `task name` input.
216
216
  - `Tasks`: task list and task status.
@@ -236,6 +236,8 @@ Gateway rules:
236
236
  - One phone identity binds to one desktop VCM instance.
237
237
  - The phone can manage projects and tasks available to that desktop VCM instance.
238
238
  - When the desktop UI has an active task selected, Gateway uses that task automatically.
239
+ - After binding, VCM keeps a lightweight Gateway connection for `/start` and read-only commands even when the `Gateway` toggle is off.
240
+ - VCM caches the latest PM reply for each task locally, so `/start` can immediately return the current task's latest PM status when available.
239
241
  - Plain text messages go only to the current task's `project-manager`.
240
242
  - Gateway never sends directly to `architect`, `coder`, or `reviewer`.
241
243
  - Gateway credentials and audit logs stay in local app state, not in connected repositories.
@@ -254,12 +256,12 @@ Gateway state is stored locally under:
254
256
  3. Click `Start QR Login`.
255
257
  4. VCM opens a global `Weixin Gateway Login` dialog with a QR code.
256
258
  5. Scan the QR code with Weixin and confirm login on the phone.
257
- 6. Click `Check QR` in the dialog.
259
+ 6. Click `Confirm` in the dialog.
258
260
  7. After binding succeeds, close the dialog and turn `Gateway` on in the sidebar.
259
261
 
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.
262
+ `Start QR Login` is shown only when Gateway is not bound. It creates a new Tencent iLink QR login session and opens the QR dialog. The dialog `Confirm` button asks iLink whether the QR code has been scanned and confirmed. After binding succeeds, the sidebar shows `Reset Binding` instead of `Start QR Login`.
261
263
 
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.
264
+ 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.
263
265
 
264
266
  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
267
 
@@ -273,15 +275,30 @@ When Gateway translation is on:
273
275
  - The prompt sent to PM includes only the translated English text with a `[VCM Gateway]` marker.
274
276
  - The original Chinese text is not included in the PM prompt.
275
277
  - PM replies are translated back to Chinese before VCM sends them to Weixin.
278
+ - 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.
276
279
 
277
280
  When Gateway translation is off, plain Weixin text is sent to PM as-is.
278
281
 
279
282
  ### Commands
280
283
 
281
- After Gateway is bound and turned on, send commands in the bound Weixin DM:
284
+ After Gateway is bound, send commands in the bound Weixin DM.
285
+
286
+ When `Gateway` is off, only these commands are accepted:
287
+
288
+ ```text
289
+ /help
290
+ /start
291
+ /status
292
+ /projects
293
+ /tasks
294
+ ```
295
+
296
+ When `Gateway` is on, the full command set is accepted:
282
297
 
283
298
  ```text
284
299
  /help
300
+ /start
301
+ /retry
285
302
  /status
286
303
  /projects
287
304
  /use-project <index-or-path>
@@ -295,7 +312,7 @@ After Gateway is bound and turned on, send commands in the bound Weixin DM:
295
312
  /translate off
296
313
  ```
297
314
 
298
- Plain text that does not start with `/` is sent as a message to the current task's PM session.
315
+ Plain text that does not start with `/` is sent as a message to the current task's PM session only when `Gateway` is on.
299
316
 
300
317
  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
318
 
@@ -316,6 +333,8 @@ Typical mobile flow:
316
333
 
317
334
  - `/status`: shows Gateway, binding, translation, current project, current task, and last poll status.
318
335
  - `/status` also adopts the current desktop project/task when one is selected.
336
+ - `/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.
337
+ - `/retry`: retries the latest failed Gateway output translation in the current VCM process.
319
338
  - `/projects`: lists the current/recent repositories known by the desktop VCM.
320
339
  - `/use-project <index-or-path>`: selects the Gateway's current project context.
321
340
  - `/pull-current`: runs the same fast-forward-only connected repository pull as the desktop `Pull` button.
@@ -335,12 +354,14 @@ Typical mobile flow:
335
354
  ### Troubleshooting
336
355
 
337
356
  - 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.
357
+ - If the QR status stays `wait`, confirm the login on the phone and click `Confirm` again.
339
358
  - If the QR code expires, start a new QR login.
340
359
  - 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.
360
+ - 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.
361
+ - If PM messages or task-changing commands are rejected, check that Gateway is on.
342
362
  - 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
363
  - If PM replies are not pushed, check that Gateway is on and the PM session is producing normal Claude transcript output.
364
+ - If PM reply translation fails, send `/retry` from the bound Weixin DM. Retry state is memory-only and is cleared when VCM restarts.
344
365
 
345
366
  ## Translation
346
367
 
@@ -505,7 +526,7 @@ Each directed role route has exactly one message file. Route messages are the on
505
526
 
506
527
  ## Orchestration Modes
507
528
 
508
- VCM has a task-level `Auto orchestration` switch in the role console toolbar.
529
+ VCM has a task-level `Auto orchestration` switch in the role console toolbar. New tasks default to auto orchestration.
509
530
 
510
531
  When it is off, VCM is in manual mode:
511
532
 
@@ -527,6 +548,7 @@ When it is on, VCM is in auto mode:
527
548
  - Just before terminal submission, VCM records `dispatchingAt`, waits briefly for the GUI to switch tabs, then writes to the embedded terminal.
528
549
  - After successful terminal write, VCM snapshots the delivered body as message history.
529
550
  - Claude Code `UserPromptSubmit` confirms that the prompt was accepted; VCM stores `acceptedAt` and clears the source route file if it still contains the same message.
551
+ - If `UserPromptSubmit` does not confirm the auto-delivered message, VCM retries Enter from the backend PTY and finally records `failureReason` on the message if submission is still not confirmed.
530
552
  - When the GUI observes a newly dispatching auto message, it switches the active role tab to that message's target role before the message is submitted.
531
553
  - VCM enforces sequential turn-taking from hook state: a role that has accepted a prompt is busy until its `Stop` hook fires.
532
554
  - Additional pending files to a busy target role remain non-empty and are not written to that terminal.
@@ -8,6 +8,10 @@ export function parseGatewayCommand(input) {
8
8
  switch (name) {
9
9
  case "/help":
10
10
  return { kind: "help" };
11
+ case "/start":
12
+ return { kind: "start" };
13
+ case "/retry":
14
+ return { kind: "retry" };
11
15
  case "/status":
12
16
  return { kind: "status" };
13
17
  case "/projects":
@@ -12,17 +12,27 @@ const POLL_ERROR_BACKOFF_MS = 2_000;
12
12
  const POLL_LONG_BACKOFF_MS = 30_000;
13
13
  const MAX_FAILURES_BEFORE_LONG_BACKOFF = 3;
14
14
  const DEFAULT_POLL_TIMEOUT_MS = 35_000;
15
+ const MAX_LATEST_PM_REPLY_CHARS = 8_000;
16
+ const GATEWAY_TRANSLATION_FAILURE_TEXT = "PM 回复已收到,但翻译失败。\n发送 /retry 重新翻译。";
17
+ const COMMANDS_ALLOWED_WHEN_DISABLED = new Set([
18
+ "help",
19
+ "start",
20
+ "status",
21
+ "projects",
22
+ "tasks"
23
+ ]);
15
24
  export function createGatewayService(deps) {
16
25
  const now = deps.now ?? (() => new Date().toISOString());
17
26
  let pollAbort = null;
18
27
  let pollLoopPromise = null;
19
28
  let qrLogin = null;
29
+ let lastFailedTranslation = null;
20
30
  function isRunning() {
21
31
  return Boolean(pollAbort && !pollAbort.signal.aborted);
22
32
  }
23
33
  async function ensurePolling() {
24
34
  const settings = await deps.settings.loadSettings();
25
- if (!settings.enabled || !settings.binding.token || isRunning()) {
35
+ if (!settings.binding.token || isRunning()) {
26
36
  return;
27
37
  }
28
38
  pollAbort = new AbortController();
@@ -55,7 +65,7 @@ export function createGatewayService(deps) {
55
65
  while (!signal.aborted) {
56
66
  const settings = await deps.settings.loadSettings();
57
67
  const account = toAccount(settings);
58
- if (!settings.enabled || !account) {
68
+ if (!account) {
59
69
  await savePollStatus("idle");
60
70
  return;
61
71
  }
@@ -99,6 +109,13 @@ export function createGatewayService(deps) {
99
109
  await deps.settings.saveSettings({
100
110
  ...settingsAfterError,
101
111
  enabled: expired ? false : settingsAfterError.enabled,
112
+ binding: expired
113
+ ? {
114
+ ...settingsAfterError.binding,
115
+ token: null,
116
+ getUpdatesBuf: ""
117
+ }
118
+ : settingsAfterError.binding,
102
119
  lastPollStatus: {
103
120
  state: expired ? "expired" : "error",
104
121
  checkedAt: now(),
@@ -169,7 +186,7 @@ export function createGatewayService(deps) {
169
186
  }
170
187
  const command = parseGatewayCommand(update.text);
171
188
  try {
172
- const output = await executeCommand(command);
189
+ const output = await executeCommand(command, settings);
173
190
  await reply(await deps.settings.loadSettings(), update.fromUserId, output);
174
191
  await recordMessageStatus("inbound", "ok", update.text, undefined, command.kind);
175
192
  await deps.audit.record({
@@ -196,10 +213,21 @@ export function createGatewayService(deps) {
196
213
  });
197
214
  }
198
215
  }
199
- async function executeCommand(command) {
216
+ async function executeCommand(command, settings) {
217
+ if (!settings.enabled && !COMMANDS_ALLOWED_WHEN_DISABLED.has(command.kind)) {
218
+ return [
219
+ "Gateway is connected but off.",
220
+ "Available commands while off: /help, /start, /status, /projects, /tasks.",
221
+ "Turn Gateway on in desktop VCM to send PM messages or run task-changing commands."
222
+ ].join("\n");
223
+ }
200
224
  switch (command.kind) {
201
225
  case "help":
202
- return helpText();
226
+ return helpText(settings.enabled);
227
+ case "start":
228
+ return startGateway();
229
+ case "retry":
230
+ return retryFailedTranslation();
203
231
  case "status":
204
232
  return statusText(await deps.settings.loadSettings());
205
233
  case "projects":
@@ -473,9 +501,13 @@ export function createGatewayService(deps) {
473
501
  deleteBranch: Boolean(task.worktreePath),
474
502
  forceDeleteBranch: true
475
503
  });
504
+ clearFailedTranslation(project.repoRoot, taskSlug);
505
+ const latestPmReplies = { ...settings.latestPmReplies };
506
+ delete latestPmReplies[latestPmReplyKey(project.repoRoot, taskSlug)];
476
507
  await deps.settings.saveSettings({
477
508
  ...settings,
478
509
  currentTaskSlug: null,
510
+ latestPmReplies,
479
511
  pendingConfirmations: {
480
512
  ...settings.pendingConfirmations,
481
513
  closeTask: null
@@ -501,6 +533,30 @@ export function createGatewayService(deps) {
501
533
  const settings = await deps.settings.updateSettings({ translationEnabled: enabled });
502
534
  return `Gateway translation ${settings.translationEnabled ? "on" : "off"}.`;
503
535
  }
536
+ async function startGateway() {
537
+ const settings = await deps.settings.loadSettings();
538
+ if (!settings.binding.token) {
539
+ return "Gateway is not bound. Start QR login from desktop VCM first.";
540
+ }
541
+ if (settings.enabled) {
542
+ return "Gateway is already on.";
543
+ }
544
+ const enabled = await syncDesktopContext(await deps.settings.updateSettings({ enabled: true }));
545
+ await ensurePolling();
546
+ await deps.appSettings.updatePreferences({ flowPauseAlerts: false });
547
+ const lines = [
548
+ "Gateway started.",
549
+ "Full mobile commands and PM messages are now enabled.",
550
+ `Project: ${enabled.currentProjectId ?? "none"}`,
551
+ `Task: ${enabled.currentTaskSlug ?? "none"}`
552
+ ];
553
+ const latestReply = getLatestPmReply(enabled);
554
+ if (latestReply) {
555
+ const rendered = await renderLatestPmReply(enabled, latestReply);
556
+ lines.push("", "Latest PM reply:", rendered.text);
557
+ }
558
+ return lines.join("\n");
559
+ }
504
560
  async function sendPlainTextToPm(text) {
505
561
  if (!text.trim()) {
506
562
  return "Empty message ignored.";
@@ -589,14 +645,16 @@ export function createGatewayService(deps) {
589
645
  void stopPolling();
590
646
  },
591
647
  async getStatus() {
592
- return deps.settings.expose(await syncDesktopContext(await deps.settings.loadSettings()), isRunning());
648
+ const settings = await syncDesktopContext(await deps.settings.loadSettings());
649
+ await ensurePolling();
650
+ return deps.settings.expose(settings, isRunning());
593
651
  },
594
652
  async updateSettings(input) {
595
653
  let settings = await deps.settings.updateSettings(input);
596
654
  if (settings.enabled) {
597
655
  settings = await syncDesktopContext(settings);
598
656
  }
599
- if (settings.enabled) {
657
+ if (settings.binding.token) {
600
658
  await ensurePolling();
601
659
  }
602
660
  else {
@@ -606,6 +664,7 @@ export function createGatewayService(deps) {
606
664
  },
607
665
  async resetBinding() {
608
666
  await stopPolling();
667
+ lastFailedTranslation = null;
609
668
  const settings = await deps.settings.resetBinding();
610
669
  return deps.settings.expose(settings, isRunning());
611
670
  },
@@ -662,6 +721,7 @@ export function createGatewayService(deps) {
662
721
  updatedAt: now()
663
722
  });
664
723
  qrLogin = null;
724
+ await ensurePolling();
665
725
  }
666
726
  }
667
727
  return {
@@ -676,19 +736,23 @@ export function createGatewayService(deps) {
676
736
  if (input.session.role !== "project-manager") {
677
737
  return;
678
738
  }
739
+ const transcriptPath = resolveExistingClaudeTranscriptPath(input.session);
740
+ if (!transcriptPath) {
741
+ return;
742
+ }
743
+ const events = await readTranscriptTextEvents(transcriptPath);
744
+ const latestReply = selectLatestTurnReply(events, input.session);
745
+ if (latestReply) {
746
+ await saveLatestPmReply(input, latestReply);
747
+ }
679
748
  const settings = await deps.settings.loadSettings();
680
749
  const account = toAccount(settings);
681
750
  const boundUserId = settings.binding.boundUserId;
682
751
  if (!settings.enabled || !account || !boundUserId) {
683
752
  return;
684
753
  }
685
- const transcriptPath = resolveExistingClaudeTranscriptPath(input.session);
686
- if (!transcriptPath) {
687
- return;
688
- }
689
754
  const cursorKey = `${input.taskSlug}:project-manager:${input.session.claudeSessionId}`;
690
755
  const cursor = settings.pushCursors[cursorKey];
691
- const events = await readTranscriptTextEvents(transcriptPath);
692
756
  const nextEvents = selectEventsAfterCursor(events, cursor?.lastTranscriptEventId);
693
757
  if (nextEvents.length === 0) {
694
758
  return;
@@ -697,25 +761,17 @@ export function createGatewayService(deps) {
697
761
  if (!text) {
698
762
  return;
699
763
  }
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
- }
764
+ const output = await renderGatewayPmOutput({
765
+ settings,
766
+ repoRoot: input.repoRoot,
767
+ taskSlug: input.taskSlug,
768
+ sourceText: text
769
+ });
714
770
  await deps.channel.sendText({
715
771
  account,
716
772
  toUserId: boundUserId,
717
773
  contextToken: settings.binding.contextTokens[boundUserId],
718
- text: output
774
+ text: output.text
719
775
  });
720
776
  const lastEvent = nextEvents.at(-1);
721
777
  const current = await deps.settings.loadSettings();
@@ -731,20 +787,131 @@ export function createGatewayService(deps) {
731
787
  lastMessageStatus: {
732
788
  checkedAt: now(),
733
789
  direction: "outbound",
734
- result: "ok",
790
+ result: output.translationFailed ? "error" : "ok",
735
791
  command: "pm-stop",
736
- preview: output.slice(0, 160)
792
+ preview: output.text.slice(0, 160),
793
+ error: output.translationError
737
794
  },
738
795
  updatedAt: now()
739
796
  });
740
797
  await deps.audit.record({
741
798
  type: "gateway.pm_push",
742
- result: "ok",
799
+ result: output.translationFailed ? "error" : "ok",
743
800
  command: "pm-stop",
744
- preview: output
801
+ preview: output.text,
802
+ error: output.translationError
745
803
  });
746
804
  }
747
805
  };
806
+ function getLatestPmReply(settings) {
807
+ if (!settings.currentProjectId || !settings.currentTaskSlug) {
808
+ return undefined;
809
+ }
810
+ return settings.latestPmReplies[latestPmReplyKey(settings.currentProjectId, settings.currentTaskSlug)];
811
+ }
812
+ async function renderLatestPmReply(settings, reply) {
813
+ const rendered = await renderGatewayPmOutput({
814
+ settings,
815
+ repoRoot: reply.repoRoot,
816
+ taskSlug: reply.taskSlug,
817
+ sourceText: reply.text
818
+ });
819
+ return {
820
+ ...rendered,
821
+ text: reply.truncated && !rendered.translationFailed ? `${rendered.text}\n\n[truncated]` : rendered.text
822
+ };
823
+ }
824
+ async function renderGatewayPmOutput(input) {
825
+ if (!input.settings.translationEnabled) {
826
+ clearFailedTranslation(input.repoRoot, input.taskSlug);
827
+ return {
828
+ text: input.sourceText,
829
+ translationFailed: false
830
+ };
831
+ }
832
+ try {
833
+ const text = await deps.translationService.translateGatewayOutput({
834
+ repoRoot: input.repoRoot,
835
+ taskSlug: input.taskSlug,
836
+ role: "project-manager",
837
+ text: input.sourceText
838
+ });
839
+ clearFailedTranslation(input.repoRoot, input.taskSlug);
840
+ return {
841
+ text,
842
+ translationFailed: false
843
+ };
844
+ }
845
+ catch (error) {
846
+ const message = errorMessage(error);
847
+ lastFailedTranslation = {
848
+ repoRoot: input.repoRoot,
849
+ taskSlug: input.taskSlug,
850
+ role: "project-manager",
851
+ sourceText: input.sourceText,
852
+ failedAt: now(),
853
+ error: message,
854
+ retryCount: 0
855
+ };
856
+ return {
857
+ text: GATEWAY_TRANSLATION_FAILURE_TEXT,
858
+ translationFailed: true,
859
+ translationError: message
860
+ };
861
+ }
862
+ }
863
+ async function retryFailedTranslation() {
864
+ if (!lastFailedTranslation) {
865
+ return "没有可重试的翻译失败内容。";
866
+ }
867
+ const failed = lastFailedTranslation;
868
+ try {
869
+ const text = await deps.translationService.translateGatewayOutput({
870
+ repoRoot: failed.repoRoot,
871
+ taskSlug: failed.taskSlug,
872
+ role: failed.role,
873
+ text: failed.sourceText
874
+ });
875
+ lastFailedTranslation = null;
876
+ return `重新翻译成功:\n\n${text}`;
877
+ }
878
+ catch (error) {
879
+ lastFailedTranslation = {
880
+ ...failed,
881
+ failedAt: now(),
882
+ error: errorMessage(error),
883
+ retryCount: failed.retryCount + 1
884
+ };
885
+ return "重新翻译失败。\n稍后可继续发送 /retry。";
886
+ }
887
+ }
888
+ function clearFailedTranslation(repoRoot, taskSlug) {
889
+ if (lastFailedTranslation?.repoRoot === repoRoot && lastFailedTranslation.taskSlug === taskSlug) {
890
+ lastFailedTranslation = null;
891
+ }
892
+ }
893
+ async function saveLatestPmReply(input, reply) {
894
+ const settings = await deps.settings.loadSettings();
895
+ const key = latestPmReplyKey(input.repoRoot, input.taskSlug);
896
+ await deps.settings.saveSettings({
897
+ ...settings,
898
+ latestPmReplies: {
899
+ ...settings.latestPmReplies,
900
+ [key]: {
901
+ repoRoot: input.repoRoot,
902
+ taskSlug: input.taskSlug,
903
+ sessionId: input.session.id,
904
+ claudeSessionId: input.session.claudeSessionId,
905
+ transcriptEventId: reply.transcriptEventId,
906
+ transcriptTimestamp: reply.transcriptTimestamp,
907
+ capturedAt: now(),
908
+ text: reply.text,
909
+ truncated: reply.truncated
910
+ }
911
+ },
912
+ updatedAt: now()
913
+ });
914
+ }
748
915
  }
749
916
  async function readTranscriptTextEvents(transcriptPath) {
750
917
  const raw = await readFile(transcriptPath, "utf8");
@@ -778,15 +945,79 @@ function selectEventsAfterCursor(events, cursorId) {
778
945
  }
779
946
  return events.slice(index + 1);
780
947
  }
948
+ function selectLatestTurnReply(events, session) {
949
+ if (events.length === 0) {
950
+ return undefined;
951
+ }
952
+ const startMs = timestampMs(session.lastTurnStartedAt);
953
+ const endMs = timestampMs(session.lastTurnEndedAt);
954
+ const selected = startMs === undefined
955
+ ? events.slice(-1)
956
+ : events.filter((event) => {
957
+ const eventMs = timestampMs(event.timestamp);
958
+ return eventMs !== undefined
959
+ && eventMs >= startMs - 1_000
960
+ && (endMs === undefined || eventMs <= endMs + 1_000);
961
+ });
962
+ if (selected.length === 0) {
963
+ return undefined;
964
+ }
965
+ const text = selected.map((event) => event.text).join("\n\n").trim();
966
+ if (!text) {
967
+ return undefined;
968
+ }
969
+ const limited = limitLatestPmReply(text);
970
+ const lastEvent = selected.at(-1);
971
+ return {
972
+ transcriptEventId: lastEvent?.id ?? null,
973
+ transcriptTimestamp: lastEvent?.timestamp ?? null,
974
+ text: limited.text,
975
+ truncated: limited.truncated
976
+ };
977
+ }
978
+ function latestPmReplyKey(repoRoot, taskSlug) {
979
+ return JSON.stringify([repoRoot, taskSlug]);
980
+ }
981
+ function limitLatestPmReply(text) {
982
+ if (text.length <= MAX_LATEST_PM_REPLY_CHARS) {
983
+ return { text, truncated: false };
984
+ }
985
+ return {
986
+ text: text.slice(0, MAX_LATEST_PM_REPLY_CHARS).trimEnd(),
987
+ truncated: true
988
+ };
989
+ }
990
+ function timestampMs(value) {
991
+ if (!value) {
992
+ return undefined;
993
+ }
994
+ const parsed = Date.parse(value);
995
+ return Number.isFinite(parsed) ? parsed : undefined;
996
+ }
781
997
  function projectsText(projects) {
782
998
  if (projects.length === 0) {
783
999
  return "No recent projects. Connect a repository from desktop VCM first.";
784
1000
  }
785
1001
  return projects.map((repoPath, index) => `${index + 1}. ${repoPath}`).join("\n");
786
1002
  }
787
- function helpText() {
1003
+ function helpText(enabled) {
1004
+ if (!enabled) {
1005
+ return [
1006
+ "VCM Gateway is connected but off.",
1007
+ "Available commands:",
1008
+ "/help",
1009
+ "/start",
1010
+ "/status",
1011
+ "/projects",
1012
+ "/tasks",
1013
+ "Send /start to enable PM messages and task-changing commands."
1014
+ ].join("\n");
1015
+ }
788
1016
  return [
789
1017
  "VCM Gateway commands:",
1018
+ "/help",
1019
+ "/start",
1020
+ "/retry",
790
1021
  "/status",
791
1022
  "/projects",
792
1023
  "/use-project <index-or-path>",
@@ -48,6 +48,7 @@ export function createGatewaySettingsService(deps) {
48
48
  recentInboundMessageIds: []
49
49
  },
50
50
  pushCursors: {},
51
+ latestPmReplies: current.latestPmReplies,
51
52
  lastPollStatus: {
52
53
  state: "idle"
53
54
  },
@@ -92,6 +93,9 @@ export function normalizeSettings(input, timestamp) {
92
93
  ? input.pendingConfirmations
93
94
  : {};
94
95
  const pushCursors = isObject(input.pushCursors) ? input.pushCursors : {};
96
+ const latestPmReplies = isObject(input.latestPmReplies)
97
+ ? input.latestPmReplies
98
+ : {};
95
99
  return {
96
100
  version: 1,
97
101
  enabled: input.enabled === true,
@@ -120,6 +124,7 @@ export function normalizeSettings(input, timestamp) {
120
124
  closeTask: normalizeCloseTaskConfirmation(pendingInput.closeTask)
121
125
  },
122
126
  pushCursors: normalizePushCursors(pushCursors),
127
+ latestPmReplies: normalizeLatestPmReplies(latestPmReplies),
123
128
  lastPollStatus: normalizePollStatus(input.lastPollStatus),
124
129
  lastMessageStatus: normalizeMessageStatus(input.lastMessageStatus),
125
130
  updatedAt: typeof input.updatedAt === "string" ? input.updatedAt : timestamp
@@ -161,6 +166,35 @@ function normalizePushCursors(input) {
161
166
  }
162
167
  return out;
163
168
  }
169
+ function normalizeLatestPmReplies(input) {
170
+ const out = {};
171
+ for (const [key, value] of Object.entries(input)) {
172
+ if (!isObject(value)) {
173
+ continue;
174
+ }
175
+ const repoRoot = normalizeNullableString(value.repoRoot);
176
+ const taskSlug = normalizeNullableString(value.taskSlug);
177
+ const sessionId = normalizeNullableString(value.sessionId);
178
+ const claudeSessionId = normalizeNullableString(value.claudeSessionId);
179
+ const capturedAt = normalizeNullableString(value.capturedAt);
180
+ const text = normalizeNullableString(value.text);
181
+ if (!repoRoot || !taskSlug || !sessionId || !claudeSessionId || !capturedAt || !text) {
182
+ continue;
183
+ }
184
+ out[key] = {
185
+ repoRoot,
186
+ taskSlug,
187
+ sessionId,
188
+ claudeSessionId,
189
+ transcriptEventId: normalizeNullableString(value.transcriptEventId),
190
+ transcriptTimestamp: normalizeNullableString(value.transcriptTimestamp),
191
+ capturedAt,
192
+ text,
193
+ truncated: value.truncated === true
194
+ };
195
+ }
196
+ return out;
197
+ }
164
198
  function normalizePollStatus(input) {
165
199
  if (!isObject(input)) {
166
200
  return { state: "idle" };