@gavin-hu/my-pi-agent 0.11.3 → 0.11.4
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.
|
@@ -76,6 +76,8 @@ How the `send_wechat` call and its result look in the transcript — the call li
|
|
|
76
76
|
| `/wechat login` | QR login; on scan, stores credentials, replaces a running poller, and starts the bridge. |
|
|
77
77
|
| `/wechat new` | Start a fresh *host* session. Peer conversations are independent and survive it; they use an inbound `/new`. |
|
|
78
78
|
| `/wechat status` | The same control screen as a bare `/wechat` (the one-line summary while a peer turn runs). |
|
|
79
|
+
| `/wechat pause` | Stop polling new messages; running turns may finish and reply. The poller lock is released, so another runtime may take the account. |
|
|
80
|
+
| `/wechat resume` | Reacquire the lock and poll again; reports when another runtime already holds it. |
|
|
79
81
|
| `/wechat doctor` | Print a read-only health report: connection and token age, last poll and hold, lock holder, per-peer session/queue, recent failures. |
|
|
80
82
|
| `/wechat stop <peer>` | Stop that peer's running turn; reports when nothing was running. |
|
|
81
83
|
| `/wechat reset <peer>` | Start a fresh conversation for that peer; refused while its turn runs. |
|
|
@@ -108,10 +110,18 @@ what is queued behind it), activity counters, and the waiting remote prompt
|
|
|
108
110
|
`j`/`k` (or the arrows) move the focus over peer rows; `s` stops the focused
|
|
109
111
|
peer's turn and `d` starts a fresh conversation for it, both in place; `h` closes
|
|
110
112
|
the screen and prints that peer's history; `a` closes it and offers the refused
|
|
111
|
-
senders to allow; `c` withdraws the waiting prompt
|
|
112
|
-
`n` starts a new session, `o` logs out,
|
|
113
|
-
subcommands above are shortcuts for the same
|
|
114
|
-
the TUI; an unknown subcommand reports the
|
|
113
|
+
senders to allow; `c` withdraws the waiting prompt; `p` pauses or resumes
|
|
114
|
+
polling. `l` logs in or reconnects, `n` starts a new session, `o` logs out,
|
|
115
|
+
`r` refreshes, and Esc closes. The subcommands above are shortcuts for the same
|
|
116
|
+
actions and the only route outside the TUI; an unknown subcommand reports the
|
|
117
|
+
valid ones.
|
|
118
|
+
|
|
119
|
+
Pause is per runtime and keeps the account and peer sessions: a running turn
|
|
120
|
+
may finish and reply, a waiting prompt is withdrawn with the timeout notice,
|
|
121
|
+
and the poller lock is released so another runtime can take over. Resume
|
|
122
|
+
reacquires the lock and reports when another runtime holds it. `/reload` or a
|
|
123
|
+
restart builds a new bridge and resumes polling, so pause is a temporary mute,
|
|
124
|
+
not a persistent switch.
|
|
115
125
|
|
|
116
126
|
A peer turn runs in its own session, but it still owns the bridge, so a `/wechat`
|
|
117
127
|
command typed at the terminal declines while one is running: `status` prints the
|
|
@@ -395,16 +405,16 @@ sessions are in-process).
|
|
|
395
405
|
|
|
396
406
|
| Integration point | Detail |
|
|
397
407
|
|---|---|
|
|
398
|
-
| Command | `pi.registerCommand("wechat", …)` — `login`, `new`, `status`, `doctor`, `stop <peer>`, `reset <peer>`, `history <peer> [n]`, `allow [peer]`, `logout`, `help`; bare `/wechat` opens the control screen. |
|
|
408
|
+
| Command | `pi.registerCommand("wechat", …)` — `login`, `new`, `status`, `pause`, `resume`, `doctor`, `stop <peer>`, `reset <peer>`, `history <peer> [n]`, `allow [peer]`, `logout`, `help`; bare `/wechat` opens the control screen. |
|
|
399
409
|
| Tools | `send_wechat` — owner-only proactive text; registered at factory time with `exposure: "direct"`, `executionMode: "sequential"`, `openWorldHint: true`. |
|
|
400
410
|
| Events | `session_start` (record the live context, auto-start the bridge when an account exists); `session_shutdown` (idempotent stop). No host run events: peer turns are not the host session's runs. |
|
|
401
411
|
| Sessions | `openPeerSession` creates one SDK `AgentSession` per peer (`createAgentSession`, `SessionManager.continueRecent` under `<agent-dir>/wechat/sessions/<peer>/`), inheriting the host's model and thinking level. `DefaultResourceLoader` standard discovery minus this bridge (`withoutWechatExtensions`), so a peer session cannot start a second poller, plus a short channel contract appended to the system prompt so the model knows it is answering in a phone chat window. When `peers[<id>].tools` is set, only those names stay active. |
|
|
402
412
|
| Turn ownership | `peer-worker.ts`: a serial queue per peer, `session.prompt()`/`session.abort()`, `getLastAssistantText()` for the answer, and one idle sweep per session. `concurrency` defaults to 1 so a forwarded dialog has one owner. |
|
|
403
413
|
| Slash commands | Admitted inbound text is matched against `SLASH_COMMANDS` in `handleMessage`; a snippet is expanded into the turn, an action runs out-of-band and replies, and an unknown command falls through. Actions act on the sender through `SlashSessionControls` (`newSession` → worker reset, `stop` → worker abort, `status` → worker state, `history` → a read-only read of that peer's newest session). |
|
|
404
414
|
| Peer dialogs | `bindExtensions({ mode: "rpc", uiContext })` on each peer session, with `createPeerUIContext(channel)` routing `select`/`confirm`/`input`/`editor` to that peer and marking the context for `askHuman`'s file/text preface. The terminal's shared `ctx.ui` is deliberately not adapted: a host turn is never answered remotely, so its dialogs stay local even while a peer turn runs. |
|
|
405
|
-
| Status | The bridge's connection state (`running` / `expired` / `stopped`) is published through `onStateChange` to `ctx.ui.setStatus(STATUS_KEYS.wechat, …)`; an expiry keeps a persistent error chip, and the status bar renders it on line 2. |
|
|
415
|
+
| Status | The bridge's connection state (`running` / `paused` / `expired` / `stopped`) is published through `onStateChange` to `ctx.ui.setStatus(STATUS_KEYS.wechat, …)`; an expiry keeps a persistent error chip, pause keeps a warning chip, and the status bar renders it on line 2. |
|
|
406
416
|
| State | Files under `<agent-dir>/wechat/`; the bridge is not authoritative UI, so nothing is stored in the transcript. |
|
|
407
|
-
| Lifecycle | The factory only registers. The poll loop starts on `session_start` when an account is stored (and after `/wechat login`); `session_shutdown` closes it. One bridge per extension runtime: it is built once, `open()` is a no-op while running (so the RPC double `session_start` on a session switch cannot start a second poller), and shutdown is idempotent, so the reload and replacement paths (`/reload`, `/new`, resume, fork — all of which re-run the factory and emit shutdown before the new start) each end with exactly one poller. `/wechat` screens use `ctx.ui.custom`, so they degrade (status) or decline (login, logout) while a peer turn runs. |
|
|
417
|
+
| Lifecycle | The factory only registers. The poll loop starts on `session_start` when an account is stored (and after `/wechat login`); `session_shutdown` closes it. One bridge per extension runtime: it is built once, `open()` is a no-op while running or paused (so the RPC double `session_start` on a session switch cannot start a second poller or resume a paused one), and shutdown is idempotent, so the reload and replacement paths (`/reload`, `/new`, resume, fork — all of which re-run the factory and emit shutdown before the new start) each end with exactly one poller. `pause()` stops the loop and releases the lock while keeping the worker, so a running turn can finish; `resume()` reacquires the lock and starts a new loop. Pause is runtime-local, so a reload or restart starts a fresh running bridge. `/wechat` screens use `ctx.ui.custom`, so they degrade (status) or decline (login, logout) while a peer turn runs. |
|
|
408
418
|
|
|
409
419
|
## Design notes
|
|
410
420
|
|
|
@@ -65,7 +65,7 @@ export function isInboundMessage(message: WeixinMessage): message is WeixinMessa
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** The bridge's connection state, published to the UI for the status chip. */
|
|
68
|
-
export type BridgeState = "stopped" | "running" | "expired";
|
|
68
|
+
export type BridgeState = "stopped" | "running" | "expired" | "paused";
|
|
69
69
|
|
|
70
70
|
export interface BridgeDeps {
|
|
71
71
|
config: WechatConfig;
|
|
@@ -107,6 +107,8 @@ export interface BridgeStatus {
|
|
|
107
107
|
open: boolean;
|
|
108
108
|
/** Whether the bridge stopped because the session expired and needs a re-login. */
|
|
109
109
|
expired: boolean;
|
|
110
|
+
/** Whether the operator paused polling; the account and peer sessions are kept. */
|
|
111
|
+
paused: boolean;
|
|
110
112
|
owner?: string;
|
|
111
113
|
peerCount: number;
|
|
112
114
|
queued: number;
|
|
@@ -146,6 +148,10 @@ export interface SendResult {
|
|
|
146
148
|
export interface Bridge {
|
|
147
149
|
open(): void;
|
|
148
150
|
close(): Promise<void>;
|
|
151
|
+
/** Stop polling new messages; running turns may finish. False when not running. */
|
|
152
|
+
pause(): Promise<boolean>;
|
|
153
|
+
/** Reacquire the lock and start polling again. False when not paused or the lock is taken. */
|
|
154
|
+
resume(): Promise<boolean>;
|
|
149
155
|
shutdown(): Promise<void>;
|
|
150
156
|
/** Stop one peer's running turn; false when that peer is idle. */
|
|
151
157
|
stop(peer: string): boolean;
|
|
@@ -165,6 +171,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
165
171
|
const sleep = deps.sleep ?? defaultSleep;
|
|
166
172
|
const armTimer = deps.armTimer ?? defaultTimer;
|
|
167
173
|
let running = false;
|
|
174
|
+
let paused = false;
|
|
168
175
|
let expired = false;
|
|
169
176
|
let startedAt: number | undefined;
|
|
170
177
|
let lastPollAt: number | undefined;
|
|
@@ -195,11 +202,12 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
195
202
|
|
|
196
203
|
/** Report the current connection state to the host, for a status chip. */
|
|
197
204
|
function publishState(): void {
|
|
198
|
-
deps.onStateChange?.(running ? "running" : expired ? "expired" : "stopped");
|
|
205
|
+
deps.onStateChange?.(running ? "running" : expired ? "expired" : paused ? "paused" : "stopped");
|
|
199
206
|
}
|
|
200
207
|
|
|
201
208
|
function handleExpired(): void {
|
|
202
209
|
running = false;
|
|
210
|
+
paused = false;
|
|
203
211
|
expired = true;
|
|
204
212
|
// An expiry can be discovered by a send while the loop is parked in
|
|
205
213
|
// `getUpdates`; abort it so it cannot wake up beside a later `open()`.
|
|
@@ -491,6 +499,21 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
491
499
|
}
|
|
492
500
|
}
|
|
493
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Serialize lifecycle transitions. A pause and a resume from the control
|
|
504
|
+
* screen, or a shutdown racing either, must not leave two poll loops or a
|
|
505
|
+
* released lock behind a running bridge.
|
|
506
|
+
*/
|
|
507
|
+
let lifecycle: Promise<void> = Promise.resolve();
|
|
508
|
+
function serial<T>(work: () => Promise<T>): Promise<T> {
|
|
509
|
+
const run = lifecycle.then(work, work);
|
|
510
|
+
lifecycle = run.then(
|
|
511
|
+
() => undefined,
|
|
512
|
+
() => undefined,
|
|
513
|
+
);
|
|
514
|
+
return run;
|
|
515
|
+
}
|
|
516
|
+
|
|
494
517
|
return {
|
|
495
518
|
open(): void {
|
|
496
519
|
if (running) return;
|
|
@@ -523,6 +546,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
523
546
|
deps.saveState(state);
|
|
524
547
|
}
|
|
525
548
|
running = true;
|
|
549
|
+
paused = false;
|
|
526
550
|
expired = false;
|
|
527
551
|
resetEphemeral();
|
|
528
552
|
startedAt = deps.now();
|
|
@@ -563,28 +587,71 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
563
587
|
});
|
|
564
588
|
},
|
|
565
589
|
|
|
566
|
-
|
|
567
|
-
|
|
590
|
+
close(): Promise<void> {
|
|
591
|
+
return serial(async () => {
|
|
592
|
+
running = false;
|
|
593
|
+
paused = false;
|
|
594
|
+
expired = false;
|
|
595
|
+
abort?.abort();
|
|
596
|
+
abort = undefined;
|
|
597
|
+
try {
|
|
598
|
+
await loopPromise;
|
|
599
|
+
} catch {
|
|
600
|
+
// Errors are surfaced by the loop's own catch.
|
|
601
|
+
}
|
|
602
|
+
loopPromise = undefined;
|
|
603
|
+
// A paused bridge has no loop and no abort controller but still owns a
|
|
604
|
+
// worker, so close it unconditionally rather than leaking the sessions.
|
|
605
|
+
const closing = worker;
|
|
606
|
+
worker = undefined;
|
|
607
|
+
await closing?.close();
|
|
608
|
+
resetEphemeral();
|
|
609
|
+
publishState();
|
|
610
|
+
releaseSafe();
|
|
611
|
+
});
|
|
612
|
+
},
|
|
613
|
+
|
|
614
|
+
pause(): Promise<boolean> {
|
|
615
|
+
return serial(async () => {
|
|
616
|
+
if (!running) return false;
|
|
617
|
+
running = false;
|
|
618
|
+
paused = true;
|
|
619
|
+
// Clear the abort controller before withdrawing the prompt: its send must
|
|
620
|
+
// not be cancelled by the poll abort that follows.
|
|
621
|
+
const stopping = abort;
|
|
622
|
+
abort = undefined;
|
|
623
|
+
if (interaction.hasPending()) interaction.cancel();
|
|
624
|
+
stopping?.abort();
|
|
625
|
+
try {
|
|
626
|
+
await loopPromise;
|
|
627
|
+
} catch {
|
|
628
|
+
// Errors are surfaced by the loop's own catch.
|
|
629
|
+
}
|
|
630
|
+
loopPromise = undefined;
|
|
631
|
+
// No poller means no lock to keep: another runtime may take the account
|
|
632
|
+
// while this one is paused. Resume reacquires it and reports a conflict.
|
|
568
633
|
releaseSafe();
|
|
569
634
|
publishState();
|
|
570
|
-
return;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
635
|
+
return true;
|
|
636
|
+
});
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
resume(): Promise<boolean> {
|
|
640
|
+
return serial(async () => {
|
|
641
|
+
if (!paused || expired || !worker || !account) return false;
|
|
642
|
+
if (!deps.acquireLock()) {
|
|
643
|
+
notify("Another Pi session is already polling this WeChat account.", "warning");
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
paused = false;
|
|
647
|
+
running = true;
|
|
648
|
+
abort = new AbortController();
|
|
649
|
+
loopPromise = poll.run(abort.signal).catch((error) => {
|
|
650
|
+
notify(`WeChat bridge stopped: ${messageOf(error)}`, "error");
|
|
651
|
+
});
|
|
652
|
+
publishState();
|
|
653
|
+
return true;
|
|
654
|
+
});
|
|
588
655
|
},
|
|
589
656
|
|
|
590
657
|
async shutdown(): Promise<void> {
|
|
@@ -609,6 +676,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
609
676
|
peerStates: states,
|
|
610
677
|
open: running,
|
|
611
678
|
expired,
|
|
679
|
+
paused,
|
|
612
680
|
owner: state.ownerId ?? account?.ilinkUserId,
|
|
613
681
|
peerCount: Object.keys(state.peers).length,
|
|
614
682
|
queued: states.reduce((total, entry) => total + entry.state.queued, 0),
|
|
@@ -61,6 +61,8 @@ export const WECHAT_COMMANDS = [
|
|
|
61
61
|
{ verb: "login", description: "Log in by QR; on a scan, store the account and start the bridge" },
|
|
62
62
|
{ verb: "new", description: "Start a fresh host session (peer conversations are independent)" },
|
|
63
63
|
{ verb: "status", description: "Open the control screen (a one-line summary outside the TUI)" },
|
|
64
|
+
{ verb: "pause", description: "Stop polling new messages; running turns may finish" },
|
|
65
|
+
{ verb: "resume", description: "Resume polling after /wechat pause" },
|
|
64
66
|
{ verb: "doctor", description: "Print a health report: token age, last poll, lock, peers, failures" },
|
|
65
67
|
{ verb: "stop", description: "Stop one peer's running turn: /wechat stop <peer>" },
|
|
66
68
|
{ verb: "reset", description: "Start a fresh conversation for one peer: /wechat reset <peer>" },
|
|
@@ -124,6 +126,36 @@ async function runDoctor(ctx: ExtensionContext, deps: CommandDeps): Promise<void
|
|
|
124
126
|
);
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
/** Pause or resume polling; the account and peer sessions stay in place. */
|
|
130
|
+
async function runPauseToggle(ctx: ExtensionContext, deps: CommandDeps, resume: boolean): Promise<void> {
|
|
131
|
+
const bridge = deps.getBridge();
|
|
132
|
+
if (!bridge) {
|
|
133
|
+
ctx.ui.notify("WeChat is not running.", "warning");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (resume) {
|
|
137
|
+
if (!bridge.status().paused) {
|
|
138
|
+
ctx.ui.notify("WeChat is not paused.");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const ok = await bridge.resume();
|
|
142
|
+
ctx.ui.notify(
|
|
143
|
+
ok ? "WeChat polling resumed." : "WeChat could not resume; check the terminal notice.",
|
|
144
|
+
ok ? "info" : "warning",
|
|
145
|
+
);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const ok = await bridge.pause();
|
|
149
|
+
ctx.ui.notify(
|
|
150
|
+
ok
|
|
151
|
+
? "WeChat polling paused. Running turns may finish; no new messages will arrive."
|
|
152
|
+
: bridge.status().paused
|
|
153
|
+
? "WeChat is already paused."
|
|
154
|
+
: "WeChat is not running.",
|
|
155
|
+
ok ? "info" : "warning",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
127
159
|
/** Add a refused sender to the project config after the operator confirms. */
|
|
128
160
|
async function runAllow(ctx: ExtensionContext, deps: CommandDeps, peerArg?: string): Promise<void> {
|
|
129
161
|
if (!ctx.hasUI) {
|
|
@@ -313,6 +345,14 @@ async function runPanel(ctx: ExtensionCommandContext, deps: CommandDeps): Promis
|
|
|
313
345
|
bridge.channel().cancel();
|
|
314
346
|
return "Cancelled the waiting prompt.";
|
|
315
347
|
},
|
|
348
|
+
togglePause: async () => {
|
|
349
|
+
const bridge = deps.getBridge();
|
|
350
|
+
if (!bridge) return "WeChat bridge is not running.";
|
|
351
|
+
if (bridge.status().paused) {
|
|
352
|
+
return (await bridge.resume()) ? "Resumed polling." : "Could not resume; see the notice.";
|
|
353
|
+
}
|
|
354
|
+
return (await bridge.pause()) ? "Paused polling. Running turns may finish." : "WeChat is not running.";
|
|
355
|
+
},
|
|
316
356
|
},
|
|
317
357
|
requestRender: () => tui.requestRender(),
|
|
318
358
|
}),
|
|
@@ -410,6 +450,10 @@ async function dispatch(
|
|
|
410
450
|
return runHistory(ctx, deps, peerArg);
|
|
411
451
|
case "allow":
|
|
412
452
|
return runAllow(ctx, deps, peerArg || undefined);
|
|
453
|
+
case "pause":
|
|
454
|
+
return runPauseToggle(ctx, deps, false);
|
|
455
|
+
case "resume":
|
|
456
|
+
return runPauseToggle(ctx, deps, true);
|
|
413
457
|
case "stop":
|
|
414
458
|
return runPeerAction(ctx, deps, "stop", peerArg);
|
|
415
459
|
case "reset":
|
|
@@ -425,7 +469,7 @@ async function dispatch(
|
|
|
425
469
|
export function registerCommands(pi: ExtensionAPI, deps: CommandDeps): void {
|
|
426
470
|
pi.registerCommand("wechat", {
|
|
427
471
|
description:
|
|
428
|
-
"WeChat bridge. /wechat opens the control screen (login | new | status | doctor | stop | reset | history | allow | logout | help)",
|
|
472
|
+
"WeChat bridge. /wechat opens the control screen (login | new | status | pause | resume | doctor | stop | reset | history | allow | logout | help)",
|
|
429
473
|
getArgumentCompletions: completeSubcommand,
|
|
430
474
|
handler: async (args, ctx) => {
|
|
431
475
|
const [sub = "status", ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
@@ -34,6 +34,7 @@ function age(now: number, at: number | undefined): string {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function connection(status: BridgeStatus | undefined, account: BotCredentials | undefined, now: number): string {
|
|
37
|
+
if (status?.paused) return "paused · run /wechat resume";
|
|
37
38
|
if (!status?.open) {
|
|
38
39
|
return status?.expired ? "expired · run /wechat login" : "closed";
|
|
39
40
|
}
|
|
@@ -43,6 +43,8 @@ export function wechatChip(theme: Theme, state: BridgeState): string | undefined
|
|
|
43
43
|
return paint(theme, "ok", `${GLYPHS.wechat} wechat`);
|
|
44
44
|
case "expired":
|
|
45
45
|
return paint(theme, "bad", `${GLYPHS.wechat} wechat expired`);
|
|
46
|
+
case "paused":
|
|
47
|
+
return paint(theme, "warn", `${GLYPHS.wechat} wechat paused`);
|
|
46
48
|
case "stopped":
|
|
47
49
|
return undefined;
|
|
48
50
|
}
|
|
@@ -154,7 +156,7 @@ export default function wechat(pi: ExtensionAPI, deps: WechatDeps = {}): void {
|
|
|
154
156
|
const autoStart = (ctx: ExtensionContext): void => {
|
|
155
157
|
if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
|
|
156
158
|
if (!primaryAccount()) return;
|
|
157
|
-
if (bridge?.status().open) return;
|
|
159
|
+
if (bridge?.status().open || bridge?.status().paused) return;
|
|
158
160
|
try {
|
|
159
161
|
bridge = bridge ?? build(ctx);
|
|
160
162
|
bridge.open();
|
|
@@ -24,7 +24,7 @@ import { peerLabel } from "./format.ts";
|
|
|
24
24
|
import type { BotCredentials } from "./types.ts";
|
|
25
25
|
|
|
26
26
|
/** User-facing connection word; the internal `BridgeState` stays running/stopped. */
|
|
27
|
-
export type StatusConnection = "open" | "expired" | "closed";
|
|
27
|
+
export type StatusConnection = "open" | "expired" | "paused" | "closed";
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* What the status screen closed with: a global action, a peer history request,
|
|
@@ -69,7 +69,7 @@ const MAX_PEER_ROWS = 5;
|
|
|
69
69
|
|
|
70
70
|
/** The connection word for a bridge status, or `closed` when there is none. */
|
|
71
71
|
export function connectionOf(status: BridgeStatus | undefined): StatusConnection {
|
|
72
|
-
return status?.open ? "open" : status?.expired ? "expired" : "closed";
|
|
72
|
+
return status?.paused ? "paused" : status?.open ? "open" : status?.expired ? "expired" : "closed";
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/** Build the view from the stored account and the live bridge status. */
|
|
@@ -146,6 +146,8 @@ function connectionText(connection: StatusConnection): string {
|
|
|
146
146
|
return "open";
|
|
147
147
|
case "expired":
|
|
148
148
|
return "expired";
|
|
149
|
+
case "paused":
|
|
150
|
+
return "paused";
|
|
149
151
|
case "closed":
|
|
150
152
|
return "closed";
|
|
151
153
|
}
|
|
@@ -158,6 +160,8 @@ function connectionRole(connection: StatusConnection): TuiRole {
|
|
|
158
160
|
return "ok";
|
|
159
161
|
case "expired":
|
|
160
162
|
return "bad";
|
|
163
|
+
case "paused":
|
|
164
|
+
return "warn";
|
|
161
165
|
case "closed":
|
|
162
166
|
return "meta";
|
|
163
167
|
}
|
|
@@ -193,6 +197,9 @@ export function statusBody(view: StatusView, theme: Theme, width: number, select
|
|
|
193
197
|
if (view.connection === "expired") {
|
|
194
198
|
lines.push("");
|
|
195
199
|
lines.push(clip(` ${paint(theme, "bad", "Session expired — press l to log in again.")}`, target));
|
|
200
|
+
} else if (view.connection === "paused") {
|
|
201
|
+
lines.push("");
|
|
202
|
+
lines.push(clip(` ${paint(theme, "warn", "Paused — press p to resume polling.")}`, target));
|
|
196
203
|
} else if (view.connection === "closed" && view.account) {
|
|
197
204
|
lines.push("");
|
|
198
205
|
lines.push(clip(` ${paint(theme, "meta", "Not connected — press l to log in again.")}`, target));
|
|
@@ -251,6 +258,8 @@ export interface StatusActions {
|
|
|
251
258
|
reset(peer: string): string;
|
|
252
259
|
/** Withdraw the waiting prompt; returns the line to flash. */
|
|
253
260
|
cancelPrompt(): string;
|
|
261
|
+
/** Pause or resume polling; async because it settles the poll loop first. */
|
|
262
|
+
togglePause(): string | Promise<string>;
|
|
254
263
|
}
|
|
255
264
|
|
|
256
265
|
export interface StatusScreenOptions {
|
|
@@ -325,6 +334,27 @@ export class StatusScreenComponent implements Component {
|
|
|
325
334
|
this.setFlash(undefined);
|
|
326
335
|
return;
|
|
327
336
|
}
|
|
337
|
+
if (data === "p" || data === "P") {
|
|
338
|
+
if (!view.bridge) {
|
|
339
|
+
this.setFlash("WeChat is not running.");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const outcome = this.options.actions?.togglePause();
|
|
343
|
+
if (!outcome) {
|
|
344
|
+
this.setFlash("Pause is unavailable here.");
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (typeof outcome === "string") {
|
|
348
|
+
this.setFlash(outcome);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.setFlash(view.connection === "paused" ? "Resuming…" : "Pausing…");
|
|
352
|
+
void outcome.then(
|
|
353
|
+
(line) => this.setFlash(line),
|
|
354
|
+
() => this.setFlash("Pause action failed."),
|
|
355
|
+
);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
328
358
|
if (data === "a" || data === "A") {
|
|
329
359
|
this.options.onClose({ kind: "allow" });
|
|
330
360
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gavin-hu/my-pi-agent",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, subagent, job, watch, web-access, file-browse, file-open, document, memory, wechat, and bytefly.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|