@gavin-hu/my-pi-agent 0.14.0 → 0.15.0
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 +55 -33
- package/README.zh-CN.md +44 -27
- package/extensions/bytefly/README.md +193 -140
- package/extensions/bytefly/block/component.ts +7 -15
- package/extensions/bytefly/block/register.ts +6 -1
- package/extensions/bytefly/doctor/register.ts +24 -5
- package/extensions/bytefly/doctor/tools.ts +4 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
- package/extensions/bytefly/footer/config.ts +10 -6
- package/extensions/bytefly/footer/price/price.ts +5 -3
- package/extensions/bytefly/footer/price/rates.ts +63 -26
- package/extensions/bytefly/footer/render/status.ts +3 -3
- package/extensions/bytefly/header/component.ts +3 -2
- package/extensions/bytefly/index.ts +3 -3
- package/extensions/bytefly/notice/component.ts +8 -3
- package/extensions/bytefly/notify/config.ts +6 -11
- package/extensions/bytefly/notify/dispatch.ts +13 -5
- package/extensions/bytefly/panel/commands.ts +21 -1
- package/extensions/bytefly/panel/format.ts +5 -2
- package/extensions/bytefly/panel/settings-runtime.ts +26 -6
- package/extensions/bytefly/panel/settings.ts +41 -4
- package/extensions/bytefly/panel/themes.ts +32 -0
- package/extensions/bytefly/panel/tui/editing.ts +77 -0
- package/extensions/bytefly/panel/tui/entries.ts +24 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
- package/extensions/bytefly/panel/tui/frame.ts +121 -4
- package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
- package/extensions/bytefly/panel/tui.ts +94 -152
- package/extensions/bytefly/picker/discovery.ts +1 -0
- package/extensions/bytefly/picker/types.ts +2 -0
- package/extensions/bytefly/separator/component.ts +14 -4
- package/extensions/bytefly/update/cache.ts +58 -19
- package/extensions/bytefly/update/check.ts +35 -0
- package/extensions/bytefly/update/format.ts +76 -35
- package/extensions/bytefly/update/register.ts +144 -55
- package/extensions/bytefly/update/state.ts +4 -2
- package/extensions/bytefly/update/suppress.ts +66 -0
- package/extensions/bytefly/update/types.ts +15 -0
- package/extensions/check/README.md +253 -0
- package/extensions/check/commands.ts +128 -0
- package/extensions/check/config.ts +39 -0
- package/extensions/check/detect.ts +269 -0
- package/extensions/check/failures.ts +230 -0
- package/extensions/check/format.ts +135 -0
- package/extensions/check/index.ts +26 -0
- package/extensions/check/render.ts +185 -0
- package/extensions/check/runner.ts +176 -0
- package/extensions/check/runtime.ts +244 -0
- package/extensions/check/source.ts +80 -0
- package/extensions/check/tools.ts +173 -0
- package/extensions/check/tui.ts +195 -0
- package/extensions/check/types.ts +99 -0
- package/extensions/document/README.md +1 -1
- package/extensions/document/extract/pdf.ts +8 -16
- package/extensions/document/formats.ts +2 -2
- package/extensions/file-browse/README.md +66 -32
- package/extensions/file-browse/client.ts +95 -32
- package/extensions/file-browse/config.ts +9 -9
- package/extensions/file-browse/git.ts +37 -47
- package/extensions/file-browse/html.ts +39 -58
- package/extensions/file-browse/markdown.ts +110 -92
- package/extensions/file-browse/raw-html.ts +478 -0
- package/extensions/file-browse/router.ts +32 -70
- package/extensions/file-browse/server.ts +18 -56
- package/extensions/file-browse/urls.ts +80 -0
- package/extensions/goal/README.md +103 -47
- package/extensions/goal/clarify-parse.ts +4 -9
- package/extensions/goal/clarify.ts +27 -4
- package/extensions/goal/commands.ts +24 -11
- package/extensions/goal/format.ts +41 -10
- package/extensions/goal/index.ts +39 -16
- package/extensions/goal/plan-route.ts +51 -0
- package/extensions/goal/schema.ts +6 -23
- package/extensions/goal/state.ts +39 -57
- package/extensions/goal/tools.ts +26 -20
- package/extensions/goal/types.ts +11 -1
- package/extensions/job/README.md +20 -6
- package/extensions/job/format.ts +18 -68
- package/extensions/job/index.ts +26 -41
- package/extensions/job/log-writer.ts +208 -0
- package/extensions/job/logs.ts +3 -2
- package/extensions/job/process.ts +6 -99
- package/extensions/job/runtime.ts +42 -181
- package/extensions/job/schema.ts +1 -4
- package/extensions/job/tools.ts +1 -15
- package/extensions/job/tui.ts +117 -307
- package/extensions/job/ui.ts +7 -33
- package/extensions/memory/README.md +1 -1
- package/extensions/memory/errors.ts +5 -6
- package/extensions/memory/tui.ts +14 -24
- package/extensions/plan/README.md +94 -18
- package/extensions/plan/commands.ts +9 -5
- package/extensions/plan/format.ts +87 -0
- package/extensions/plan/index.ts +31 -15
- package/extensions/plan/plans.ts +2 -3
- package/extensions/plan/policy.ts +0 -6
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/steps.ts +37 -8
- package/extensions/plan/todos.ts +44 -3
- package/extensions/plan/tools/enter.ts +94 -0
- package/extensions/plan/tools/exit.ts +296 -0
- package/extensions/plan/tools/nested.ts +21 -0
- package/extensions/plan/tools/steps.ts +136 -0
- package/extensions/plan/tools/todo-seed.ts +76 -0
- package/extensions/plan/tools/write.ts +127 -0
- package/extensions/plan/tools.ts +18 -703
- package/extensions/plan/web-client.ts +6 -22
- package/extensions/plan/web-page.ts +19 -30
- package/extensions/plan/web-router.ts +34 -71
- package/extensions/plan/web.ts +13 -40
- package/extensions/question/answers.ts +3 -8
- package/extensions/question/tui.ts +6 -6
- package/extensions/rewind/README.md +95 -22
- package/extensions/rewind/checklist.ts +185 -0
- package/extensions/rewind/commands.ts +8 -0
- package/extensions/rewind/config.ts +3 -11
- package/extensions/rewind/format.ts +220 -2
- package/extensions/rewind/git.ts +74 -7
- package/extensions/rewind/hunk-picker.ts +114 -0
- package/extensions/rewind/patch.ts +88 -0
- package/extensions/rewind/path-picker.ts +67 -100
- package/extensions/rewind/paths.ts +93 -19
- package/extensions/rewind/restore.ts +53 -10
- package/extensions/rewind/review-tui.ts +320 -0
- package/extensions/rewind/review.ts +397 -0
- package/extensions/rewind/rewind-tui.ts +4 -3
- package/extensions/rewind/runtime.ts +94 -17
- package/extensions/subagent/README.md +61 -6
- package/extensions/subagent/background-tui.ts +208 -0
- package/extensions/subagent/background.ts +470 -0
- package/extensions/subagent/commands.ts +80 -0
- package/extensions/subagent/config.ts +4 -0
- package/extensions/subagent/format.ts +24 -1
- package/extensions/subagent/index.ts +91 -2
- package/extensions/subagent/render.ts +13 -0
- package/extensions/subagent/run.ts +17 -16
- package/extensions/subagent/schema.ts +12 -0
- package/extensions/subagent/types.ts +11 -0
- package/extensions/subagent/ui.ts +50 -0
- package/extensions/todo/README.md +18 -13
- package/extensions/todo/format.ts +3 -2
- package/extensions/todo/runtime.ts +3 -2
- package/extensions/todo/schema.ts +8 -84
- package/extensions/todo/state.ts +5 -46
- package/extensions/todo/tools.ts +2 -1
- package/extensions/todo/types.ts +6 -15
- package/extensions/watch/README.md +14 -6
- package/extensions/watch/child.ts +150 -0
- package/extensions/watch/format.ts +8 -48
- package/extensions/watch/index.ts +42 -58
- package/extensions/watch/runtime.ts +49 -128
- package/extensions/watch/schema.ts +1 -4
- package/extensions/watch/tools.ts +1 -11
- package/extensions/watch/tui.ts +103 -273
- package/extensions/watch/ui.ts +5 -32
- package/extensions/web-access/README.md +1 -1
- package/extensions/web-access/fetch/page.ts +1 -1
- package/extensions/web-access/fetch/tool.ts +6 -2
- package/extensions/web-access/search/search.ts +2 -5
- package/extensions/wechat/README.md +1 -1
- package/extensions/wechat/client.ts +23 -0
- package/extensions/wechat/commands.ts +57 -42
- package/extensions/wechat/deliver.ts +5 -9
- package/extensions/wechat/errors.ts +3 -6
- package/extensions/wechat/inbound.ts +2 -5
- package/extensions/wechat/index.ts +2 -8
- package/extensions/wechat/outbound.ts +2 -4
- package/extensions/wechat/peer-worker.ts +3 -8
- package/extensions/wechat/poll.ts +10 -9
- package/extensions/wechat/presence.ts +35 -62
- package/extensions/wechat/reply-pipeline.ts +3 -1
- package/extensions/wechat/status-screen.ts +1 -1
- package/extensions/wechat/timer.ts +57 -0
- package/extensions/worktree/config.ts +8 -20
- package/extensions/worktree/enter.ts +7 -12
- package/extensions/worktree/git.ts +38 -69
- package/extensions/worktree/index.ts +5 -4
- package/extensions/worktree/registry.ts +7 -7
- package/extensions/worktree/root-tools.ts +2 -2
- package/extensions/worktree/status.ts +3 -2
- package/lib/README.md +22 -8
- package/lib/config.ts +50 -3
- package/lib/errors.ts +12 -0
- package/lib/format.ts +50 -0
- package/lib/git/read.ts +8 -0
- package/lib/goal-state.ts +23 -8
- package/lib/http/headers.ts +54 -0
- package/lib/http/index.ts +13 -0
- package/lib/http/response.ts +46 -0
- package/lib/http/router.ts +52 -0
- package/lib/http/server.ts +91 -0
- package/lib/lines.ts +77 -0
- package/lib/pdf.ts +82 -0
- package/lib/plan-entry.ts +71 -0
- package/lib/plan-state.ts +34 -0
- package/lib/report.ts +99 -0
- package/lib/requirements.ts +25 -19
- package/lib/spawn.ts +170 -0
- package/lib/todo-state.ts +110 -23
- package/lib/tool-names.ts +7 -0
- package/lib/tool-result.ts +33 -0
- package/lib/tui/README.md +68 -3
- package/lib/tui/chrome.ts +28 -4
- package/lib/tui/columns.ts +6 -1
- package/lib/tui/confirm.ts +65 -0
- package/lib/tui/dock-screen.ts +410 -0
- package/lib/tui/glyphs.ts +2 -1
- package/lib/tui/indent.ts +1 -1
- package/lib/tui/index.ts +2 -0
- package/lib/tui/rows.ts +21 -0
- package/lib/tui/status-controller.ts +35 -1
- package/lib/tui/tool.ts +59 -1
- package/lib/web/components.ts +20 -1
- package/lib/web/markup.ts +32 -0
- package/lib/web/runtime.ts +50 -0
- package/package.json +22 -5
- package/skills/init/SKILL.md +165 -0
- package/skills/init/references/agents-md-quality.md +103 -0
- package/skills/init/references/interview-questions.md +81 -0
- package/skills/refactor/SKILL.md +141 -0
- package/extensions/watch/process.ts +0 -63
- package/extensions/web-access/fetch/pdf.ts +0 -45
|
@@ -25,6 +25,7 @@ import { isAllowedPeer, type Bridge } from "./bridge.ts";
|
|
|
25
25
|
import type { WechatClient } from "./client.ts";
|
|
26
26
|
import type { WechatConfig } from "./config.ts";
|
|
27
27
|
import { doctorReport } from "./doctor.ts";
|
|
28
|
+
import { messageOf } from "./errors.ts";
|
|
28
29
|
import { peerLabel, peerKey } from "./format.ts";
|
|
29
30
|
import { waitForLogin, type LoginOutcome } from "./login.ts";
|
|
30
31
|
import { defaultSleep } from "./timer.ts";
|
|
@@ -91,7 +92,6 @@ export function isWechatVerb(value: string): value is WechatVerb {
|
|
|
91
92
|
return (WECHAT_VERBS as readonly string[]).includes(value);
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
import { messageOf } from "./errors.ts";
|
|
95
95
|
/**
|
|
96
96
|
* Whether a peer turn is running anywhere on the bridge.
|
|
97
97
|
*
|
|
@@ -126,6 +126,54 @@ async function runDoctor(ctx: ExtensionContext, deps: CommandDeps): Promise<void
|
|
|
126
126
|
);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/** One user-facing outcome: what to say, and how a non-screen caller says it. */
|
|
130
|
+
interface VerbOutcome {
|
|
131
|
+
message: string;
|
|
132
|
+
/** The notice kind the subcommand path posts; the control screen shows the text. */
|
|
133
|
+
kind: "info" | "warning";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The control verbs, in the one place both callers read them: the control
|
|
138
|
+
* screen's keys and the `/wechat <verb>` subcommands. A peer label carries text
|
|
139
|
+
* the peer chose, so it is sanitized here rather than at each call site.
|
|
140
|
+
*/
|
|
141
|
+
function stopMessage(bridge: Bridge, peer: string): VerbOutcome {
|
|
142
|
+
const label = sanitizeLine(peerLabel(peer));
|
|
143
|
+
return bridge.stop(peer)
|
|
144
|
+
? { message: `Stopping ${label}'s turn.`, kind: "info" }
|
|
145
|
+
: { message: `${label} has no running turn.`, kind: "info" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function resetMessage(bridge: Bridge, peer: string): VerbOutcome {
|
|
149
|
+
const label = sanitizeLine(peerLabel(peer));
|
|
150
|
+
return bridge.reset(peer)
|
|
151
|
+
? { message: `Started a fresh conversation for ${label}.`, kind: "info" }
|
|
152
|
+
: { message: `${label} is running; stop it before starting a new conversation.`, kind: "info" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function cancelPromptMessage(bridge: Bridge): VerbOutcome {
|
|
156
|
+
if (!bridge.status().promptPending) return { message: "No prompt is waiting.", kind: "info" };
|
|
157
|
+
bridge.channel().cancel();
|
|
158
|
+
return { message: "Cancelled the waiting prompt.", kind: "info" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Resume polling, unless it is not paused or the bridge will not start it. */
|
|
162
|
+
async function resumeMessage(bridge: Bridge): Promise<VerbOutcome> {
|
|
163
|
+
if (!bridge.status().paused) return { message: "WeChat is not paused.", kind: "info" };
|
|
164
|
+
return (await bridge.resume())
|
|
165
|
+
? { message: "WeChat polling resumed.", kind: "info" }
|
|
166
|
+
: { message: "WeChat could not resume; check the terminal notice.", kind: "warning" };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Pause polling, unless it is already paused or the bridge is not running. */
|
|
170
|
+
async function pauseMessage(bridge: Bridge): Promise<VerbOutcome> {
|
|
171
|
+
if (bridge.status().paused) return { message: "WeChat is already paused.", kind: "warning" };
|
|
172
|
+
return (await bridge.pause())
|
|
173
|
+
? { message: "WeChat polling paused. Running turns may finish; no new messages will arrive.", kind: "info" }
|
|
174
|
+
: { message: "WeChat is not running.", kind: "warning" };
|
|
175
|
+
}
|
|
176
|
+
|
|
129
177
|
/** Pause or resume polling; the account and peer sessions stay in place. */
|
|
130
178
|
async function runPauseToggle(ctx: ExtensionContext, deps: CommandDeps, resume: boolean): Promise<void> {
|
|
131
179
|
const bridge = deps.getBridge();
|
|
@@ -133,27 +181,8 @@ async function runPauseToggle(ctx: ExtensionContext, deps: CommandDeps, resume:
|
|
|
133
181
|
ctx.ui.notify("WeChat is not running.", "warning");
|
|
134
182
|
return;
|
|
135
183
|
}
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
);
|
|
184
|
+
const outcome = await (resume ? resumeMessage(bridge) : pauseMessage(bridge));
|
|
185
|
+
ctx.ui.notify(outcome.message, outcome.kind);
|
|
157
186
|
}
|
|
158
187
|
|
|
159
188
|
/** Add a refused sender to the project config after the operator confirms. */
|
|
@@ -329,29 +358,20 @@ async function runPanel(ctx: ExtensionCommandContext, deps: CommandDeps): Promis
|
|
|
329
358
|
actions: {
|
|
330
359
|
stop: (peer) => {
|
|
331
360
|
const bridge = deps.getBridge();
|
|
332
|
-
|
|
333
|
-
return bridge.stop(peer) ? `Stopping ${peerLabel(peer)}.` : `${peerLabel(peer)} has no running turn.`;
|
|
361
|
+
return bridge ? stopMessage(bridge, peer).message : "WeChat bridge is not running.";
|
|
334
362
|
},
|
|
335
363
|
reset: (peer) => {
|
|
336
364
|
const bridge = deps.getBridge();
|
|
337
|
-
|
|
338
|
-
return bridge.reset(peer)
|
|
339
|
-
? `Started a fresh conversation for ${peerLabel(peer)}.`
|
|
340
|
-
: `${peerLabel(peer)} is running; stop it before starting fresh.`;
|
|
365
|
+
return bridge ? resetMessage(bridge, peer).message : "WeChat bridge is not running.";
|
|
341
366
|
},
|
|
342
367
|
cancelPrompt: () => {
|
|
343
368
|
const bridge = deps.getBridge();
|
|
344
|
-
|
|
345
|
-
bridge.channel().cancel();
|
|
346
|
-
return "Cancelled the waiting prompt.";
|
|
369
|
+
return bridge ? cancelPromptMessage(bridge).message : "No prompt is waiting.";
|
|
347
370
|
},
|
|
348
371
|
togglePause: async () => {
|
|
349
372
|
const bridge = deps.getBridge();
|
|
350
373
|
if (!bridge) return "WeChat bridge is not running.";
|
|
351
|
-
|
|
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.";
|
|
374
|
+
return (await (bridge.status().paused ? resumeMessage(bridge) : pauseMessage(bridge))).message;
|
|
355
375
|
},
|
|
356
376
|
},
|
|
357
377
|
requestRender: () => tui.requestRender(),
|
|
@@ -418,16 +438,11 @@ async function runPeerAction(
|
|
|
418
438
|
);
|
|
419
439
|
return;
|
|
420
440
|
}
|
|
421
|
-
const label = peerLabel(entry.peer);
|
|
422
441
|
if (verb === "stop") {
|
|
423
|
-
ctx.ui.notify(bridge
|
|
442
|
+
ctx.ui.notify(stopMessage(bridge, entry.peer).message);
|
|
424
443
|
return;
|
|
425
444
|
}
|
|
426
|
-
ctx.ui.notify(
|
|
427
|
-
bridge.reset(entry.peer)
|
|
428
|
-
? `Started a fresh conversation for ${label}.`
|
|
429
|
-
: `${label} is running; stop it before starting a new conversation.`,
|
|
430
|
-
);
|
|
445
|
+
ctx.ui.notify(resetMessage(bridge, entry.peer).message);
|
|
431
446
|
}
|
|
432
447
|
|
|
433
448
|
/** Run one verb. The control screen reuses this so a key and a subcommand agree. */
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { BotCredentials } from "./types.ts";
|
|
12
|
-
import { isSessionExpired, WechatError, type WechatClient } from "./client.ts";
|
|
12
|
+
import { isSessionExpired, rejectionOf, WechatError, type WechatClient } from "./client.ts";
|
|
13
13
|
import { messageOf } from "./errors.ts";
|
|
14
14
|
import { chunkText, toWechatText } from "./format.ts";
|
|
15
15
|
import { sendLocalFile } from "./outbound.ts";
|
|
@@ -105,14 +105,10 @@ export function createDeliverer(deps: DeliveryDeps): Deliverer {
|
|
|
105
105
|
}
|
|
106
106
|
// A body-level rejection is not a delivery: report it with the part count
|
|
107
107
|
// instead of letting the answer vanish.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
// The server's own description is the only way to tell a bad token
|
|
113
|
-
// from a rate limit, so it is part of the error.
|
|
114
|
-
const detail = response.errmsg ? `: ${response.errmsg}` : "";
|
|
115
|
-
onFailure(new WechatError(`Weixin rejected the message (ret ${code}${detail}).`), {
|
|
108
|
+
const rejection = rejectionOf(response);
|
|
109
|
+
if (rejection) {
|
|
110
|
+
const detail = rejection.detail ? `: ${rejection.detail}` : "";
|
|
111
|
+
onFailure(new WechatError(`Weixin rejected the message (ret ${rejection.code}${detail}).`), {
|
|
116
112
|
sent: index,
|
|
117
113
|
total: chunks.length,
|
|
118
114
|
});
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one place an unknown thrown value becomes a user-facing line.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* The definition is shared in `lib/errors.ts`; this module keeps the
|
|
5
|
+
* extension-local import path every WeChat notice and tool error uses.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
export function messageOf(error: unknown): string {
|
|
10
|
-
return error instanceof Error ? error.message : String(error);
|
|
11
|
-
}
|
|
8
|
+
export { messageOf } from "../../lib/errors.ts";
|
|
@@ -112,6 +112,8 @@ export function createInboundAdmission(deps: InboundDeps): InboundAdmission {
|
|
|
112
112
|
if (item.media.length > 0) {
|
|
113
113
|
const resolved = await resolveMedia(item);
|
|
114
114
|
if (!deps.isRunning()) return;
|
|
115
|
+
// The bridge has already refused a message with no text and no media, so
|
|
116
|
+
// this is the other case: media arrived but nothing in it could be read.
|
|
115
117
|
if (!text && resolved.images.length === 0 && resolved.files.length === 0) {
|
|
116
118
|
if (resolved.dropped > 0) prefix = notice("media", item.language, resolved.dropped);
|
|
117
119
|
await deps.reply(item, prefix ?? notice("unsupported", item.language));
|
|
@@ -125,11 +127,6 @@ export function createInboundAdmission(deps: InboundDeps): InboundAdmission {
|
|
|
125
127
|
if (resolved.files.length > 0) text = [text, ...resolved.files].filter(Boolean).join("\n");
|
|
126
128
|
images = resolved.images.length > 0 ? resolved.images : undefined;
|
|
127
129
|
}
|
|
128
|
-
if (!text && !images) {
|
|
129
|
-
await deps.reply(item, notice("unsupported", item.language));
|
|
130
|
-
deps.pruneTyping();
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
130
|
const queued: PeerItem = {
|
|
134
131
|
peer: item.peer,
|
|
135
132
|
text,
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { getAgentDir, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { isExtensionEnabled } from "../../lib/env.ts";
|
|
18
|
+
import { isPidAlive } from "../../lib/spawn.ts";
|
|
18
19
|
import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
|
|
19
20
|
import { paint } from "../../lib/tui/tokens.ts";
|
|
20
21
|
import { createBridge, type Bridge, type BridgeState } from "./bridge.ts";
|
|
@@ -74,14 +75,7 @@ export default function wechat(pi: ExtensionAPI, deps: WechatDeps = {}): void {
|
|
|
74
75
|
const lockDeps = () => ({
|
|
75
76
|
now: () => Date.now(),
|
|
76
77
|
pid: process.pid,
|
|
77
|
-
isAlive:
|
|
78
|
-
try {
|
|
79
|
-
process.kill(pid, 0);
|
|
80
|
-
return true;
|
|
81
|
-
} catch {
|
|
82
|
-
return false;
|
|
83
|
-
}
|
|
84
|
-
},
|
|
78
|
+
isAlive: isPidAlive,
|
|
85
79
|
});
|
|
86
80
|
|
|
87
81
|
const build = (ctx: ExtensionContext): Bridge => {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { createHash, randomBytes } from "node:crypto";
|
|
14
14
|
import { readFileSync } from "node:fs";
|
|
15
15
|
import { basename } from "node:path";
|
|
16
|
-
import type
|
|
16
|
+
import { rejectionOf, type WechatClient } from "./client.ts";
|
|
17
17
|
import { aesEcbPaddedSize, encryptEcb, sanitizeFileName, uploadUrlOf } from "./media.ts";
|
|
18
18
|
import type { BotCredentials, CDNMedia } from "./types.ts";
|
|
19
19
|
|
|
@@ -93,9 +93,7 @@ export async function sendLocalFile(args: SendLocalFileArgs): Promise<boolean> {
|
|
|
93
93
|
args.signal,
|
|
94
94
|
);
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
const codeBad = response.errcode !== undefined && response.errcode !== 0;
|
|
98
|
-
return !retBad && !codeBad;
|
|
96
|
+
return rejectionOf(response) === undefined;
|
|
99
97
|
} catch {
|
|
100
98
|
return false;
|
|
101
99
|
}
|
|
@@ -27,6 +27,7 @@ import type { ImageContent } from "@earendil-works/pi-ai";
|
|
|
27
27
|
import { messageOf } from "./errors.ts";
|
|
28
28
|
import type { PeerSession } from "./peer-session.ts";
|
|
29
29
|
import { createReplyPipeline } from "./reply-pipeline.ts";
|
|
30
|
+
import { defaultTimer, type ArmTimer } from "./timer.ts";
|
|
30
31
|
|
|
31
32
|
/** One admitted, media-resolved inbound message. */
|
|
32
33
|
export interface PeerItem {
|
|
@@ -66,7 +67,7 @@ export interface PeerWorkerDeps {
|
|
|
66
67
|
partChars?: number;
|
|
67
68
|
quietMs?: number;
|
|
68
69
|
now?: () => number;
|
|
69
|
-
armTimer?:
|
|
70
|
+
armTimer?: ArmTimer;
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
/** What a peer is doing right now, for the control screen. */
|
|
@@ -134,13 +135,7 @@ function mergeItems(first: PeerItem, next: PeerItem): PeerItem {
|
|
|
134
135
|
|
|
135
136
|
export function createPeerWorker(deps: PeerWorkerDeps): PeerWorker {
|
|
136
137
|
const now = deps.now ?? Date.now;
|
|
137
|
-
const armTimer =
|
|
138
|
-
deps.armTimer ??
|
|
139
|
-
((ms, fn) => {
|
|
140
|
-
const timer = setTimeout(fn, ms);
|
|
141
|
-
(timer as { unref?: () => void }).unref?.();
|
|
142
|
-
return () => clearTimeout(timer);
|
|
143
|
-
});
|
|
138
|
+
const armTimer = deps.armTimer ?? defaultTimer;
|
|
144
139
|
const idleTimeoutMs = deps.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
145
140
|
const concurrency = Math.max(1, deps.concurrency ?? 1);
|
|
146
141
|
const peers = new Map<string, PeerEntry>();
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { BotCredentials, WeixinMessage } from "./types.ts";
|
|
12
|
-
import { isSessionExpired, type WechatClient } from "./client.ts";
|
|
12
|
+
import { isSessionExpired, rejectionOf, type WechatClient } from "./client.ts";
|
|
13
13
|
import { messageOf } from "./errors.ts";
|
|
14
|
+
import { defaultTimer } from "./timer.ts";
|
|
14
15
|
|
|
15
16
|
const BACKOFF_MS = 2000;
|
|
16
17
|
/** Poll watchdog when the server has not told us its long-poll hold yet. */
|
|
@@ -73,10 +74,10 @@ export function createPollLoop(deps: PollDeps): PollLoop {
|
|
|
73
74
|
const watchdogMs = deps.pollTimeout(serverHint ?? deps.holdMs());
|
|
74
75
|
// A cancellable watchdog: `AbortSignal.timeout` cannot be cleared, and a
|
|
75
76
|
// pending timer per poll would keep the process awake long after the request
|
|
76
|
-
// settled.
|
|
77
|
+
// settled. The shared seam unrefs it, so a wedged poll never holds a
|
|
78
|
+
// shutdown open either.
|
|
77
79
|
const watchdog = new AbortController();
|
|
78
|
-
const
|
|
79
|
-
(watchdogTimer as { unref?: () => void }).unref?.();
|
|
80
|
+
const cancelWatchdog = defaultTimer(watchdogMs, () => watchdog.abort());
|
|
80
81
|
let response;
|
|
81
82
|
try {
|
|
82
83
|
response = await client.getUpdates(account, deps.cursor(), AbortSignal.any([signal, watchdog.signal]), {
|
|
@@ -106,7 +107,7 @@ export function createPollLoop(deps: PollDeps): PollLoop {
|
|
|
106
107
|
}
|
|
107
108
|
continue;
|
|
108
109
|
} finally {
|
|
109
|
-
|
|
110
|
+
cancelWatchdog();
|
|
110
111
|
}
|
|
111
112
|
if (!response) break;
|
|
112
113
|
if (response.timeoutMs !== undefined && response.timeoutMs > 0) serverHint = response.timeoutMs;
|
|
@@ -114,14 +115,14 @@ export function createPollLoop(deps: PollDeps): PollLoop {
|
|
|
114
115
|
deps.onExpired();
|
|
115
116
|
return;
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
+
const rejection = rejectionOf(response);
|
|
119
|
+
if (rejection) {
|
|
118
120
|
// A body-level rejection (bad cursor, rate limit, revoked token) is not an
|
|
119
121
|
// empty poll: report it once and back off instead of hammering the API.
|
|
120
122
|
if (!rejectedNotified) {
|
|
121
123
|
rejectedNotified = true;
|
|
122
|
-
|
|
123
|
-
deps.
|
|
124
|
-
deps.notify(`WeChat poll rejected (ret ${code}); retrying.`, "warning");
|
|
124
|
+
deps.onPoll?.({ at: deps.now(), error: `rejected ret ${rejection.code}` });
|
|
125
|
+
deps.notify(`WeChat poll rejected (ret ${rejection.code}); retrying.`, "warning");
|
|
125
126
|
}
|
|
126
127
|
try {
|
|
127
128
|
await deps.sleep(BACKOFF_MS, signal);
|
|
@@ -14,7 +14,7 @@ import type { WechatClient } from "./client.ts";
|
|
|
14
14
|
import type { PeerState } from "./peer-worker.ts";
|
|
15
15
|
import type { PeerLanguage } from "./format.ts";
|
|
16
16
|
import { notice } from "./notices.ts";
|
|
17
|
-
import type
|
|
17
|
+
import { createTimerMap, type ArmTimer } from "./timer.ts";
|
|
18
18
|
|
|
19
19
|
/** How long a fetched typing ticket is reused. */
|
|
20
20
|
const TYPING_TTL_MS = 5 * 60 * 1000;
|
|
@@ -78,16 +78,16 @@ export interface Presence {
|
|
|
78
78
|
export function createPresence(deps: PresenceDeps): Presence {
|
|
79
79
|
const typingCache = new Map<string, { ticket?: string; checkedAt: number }>();
|
|
80
80
|
/** Per-peer keep-alive cancellers for the typing indicator. */
|
|
81
|
-
const typing =
|
|
82
|
-
/** Per-peer pending acknowledgement
|
|
83
|
-
const acks =
|
|
81
|
+
const typing = createTimerMap(deps.armTimer);
|
|
82
|
+
/** Per-peer pending acknowledgement timers. */
|
|
83
|
+
const acks = createTimerMap(deps.armTimer);
|
|
84
84
|
/** When each peer was last acknowledged, so a chatty peer is not spammed. */
|
|
85
85
|
const ackedAt = new Map<string, number>();
|
|
86
86
|
/** Peers told that a turn is still running, until that turn ends. */
|
|
87
87
|
const busyNotified = new Set<string>();
|
|
88
|
-
/** Per-peer `busy` and `stillWorking`
|
|
89
|
-
const busyTimers =
|
|
90
|
-
const slowTimers =
|
|
88
|
+
/** Per-peer `busy` and `stillWorking` timers. */
|
|
89
|
+
const busyTimers = createTimerMap(deps.armTimer);
|
|
90
|
+
const slowTimers = createTimerMap(deps.armTimer);
|
|
91
91
|
|
|
92
92
|
const hasWork = (peer: string): boolean => deps.statusOf(peer) !== "idle";
|
|
93
93
|
const isRunning = (peer: string): boolean => deps.statusOf(peer) === "running";
|
|
@@ -142,10 +142,7 @@ export function createPresence(deps: PresenceDeps): Presence {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
function stopTyping(peer: string): void {
|
|
145
|
-
|
|
146
|
-
if (!cancel) return;
|
|
147
|
-
typing.delete(peer);
|
|
148
|
-
cancel();
|
|
145
|
+
typing.stop(peer);
|
|
149
146
|
}
|
|
150
147
|
|
|
151
148
|
/**
|
|
@@ -160,61 +157,45 @@ export function createPresence(deps: PresenceDeps): Presence {
|
|
|
160
157
|
const started = deps.now();
|
|
161
158
|
const refresh = (): void => {
|
|
162
159
|
if (!hasWork(peer) || deps.now() - started >= TYPING_LIMIT_MS) {
|
|
163
|
-
typing.
|
|
160
|
+
typing.stop(peer);
|
|
164
161
|
return;
|
|
165
162
|
}
|
|
166
163
|
void typingOn(peer, contextToken);
|
|
167
|
-
typing.
|
|
164
|
+
typing.arm(peer, TYPING_KEEPALIVE_MS, refresh);
|
|
168
165
|
};
|
|
169
|
-
typing.
|
|
166
|
+
typing.arm(peer, TYPING_KEEPALIVE_MS, refresh);
|
|
170
167
|
}
|
|
171
168
|
|
|
172
169
|
function stopSlowNotice(peer: string): void {
|
|
173
|
-
|
|
174
|
-
if (cancel) cancel();
|
|
175
|
-
slowTimers.delete(peer);
|
|
170
|
+
slowTimers.stop(peer);
|
|
176
171
|
}
|
|
177
172
|
|
|
178
173
|
function stopBusyNotice(peer: string): void {
|
|
179
|
-
|
|
180
|
-
if (cancel) cancel();
|
|
181
|
-
busyTimers.delete(peer);
|
|
174
|
+
busyTimers.stop(peer);
|
|
182
175
|
busyNotified.delete(peer);
|
|
183
176
|
}
|
|
184
177
|
|
|
185
178
|
function armSlowNotice(peer: string, contextToken: string | undefined): void {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
// prompt itself nudges, and saying both reads as noise.
|
|
194
|
-
if (deps.promptPending()) return;
|
|
195
|
-
void deps.send(peer, contextToken, notice("stillWorking", deps.languageOf(peer)));
|
|
196
|
-
}),
|
|
197
|
-
);
|
|
179
|
+
slowTimers.arm(peer, TURN_NOTICE_MS, () => {
|
|
180
|
+
if (!isRunning(peer)) return;
|
|
181
|
+
// A turn waiting on an unanswered dialog is not "still working": the
|
|
182
|
+
// prompt itself nudges, and saying both reads as noise.
|
|
183
|
+
if (deps.promptPending()) return;
|
|
184
|
+
void deps.send(peer, contextToken, notice("stillWorking", deps.languageOf(peer)));
|
|
185
|
+
});
|
|
198
186
|
}
|
|
199
187
|
|
|
200
188
|
function armBusyNotice(peer: string, contextToken: string | undefined): void {
|
|
201
189
|
if (busyNotified.has(peer) || busyTimers.has(peer)) return;
|
|
202
|
-
busyTimers.
|
|
203
|
-
peer
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
busyNotified.add(peer);
|
|
208
|
-
void deps.send(peer, contextToken, notice("busy", deps.languageOf(peer)));
|
|
209
|
-
}),
|
|
210
|
-
);
|
|
190
|
+
busyTimers.arm(peer, BUSY_NOTICE_MS, () => {
|
|
191
|
+
if (!hasWork(peer) || busyNotified.has(peer)) return;
|
|
192
|
+
busyNotified.add(peer);
|
|
193
|
+
void deps.send(peer, contextToken, notice("busy", deps.languageOf(peer)));
|
|
194
|
+
});
|
|
211
195
|
}
|
|
212
196
|
|
|
213
197
|
function cancelAck(peer: string): void {
|
|
214
|
-
|
|
215
|
-
if (!cancel) return;
|
|
216
|
-
acks.delete(peer);
|
|
217
|
-
cancel();
|
|
198
|
+
acks.stop(peer);
|
|
218
199
|
}
|
|
219
200
|
|
|
220
201
|
/**
|
|
@@ -229,25 +210,21 @@ export function createPresence(deps: PresenceDeps): Presence {
|
|
|
229
210
|
if (acks.has(target.peer)) return;
|
|
230
211
|
const last = ackedAt.get(target.peer);
|
|
231
212
|
if (last !== undefined && deps.now() - last < ACK_REPEAT_MS) return;
|
|
232
|
-
acks.
|
|
233
|
-
target.peer
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
// Still waiting after a minute behind another turn: say why.
|
|
240
|
-
armBusyNotice(target.peer, target.contextToken);
|
|
241
|
-
}),
|
|
242
|
-
);
|
|
213
|
+
acks.arm(target.peer, ACK_DELAY_MS, () => {
|
|
214
|
+
if (!hasWork(target.peer) || isRunning(target.peer)) return;
|
|
215
|
+
ackedAt.set(target.peer, deps.now());
|
|
216
|
+
void deps.send(target.peer, target.contextToken, notice("ack", deps.languageOf(target.peer)));
|
|
217
|
+
// Still waiting after a minute behind another turn: say why.
|
|
218
|
+
armBusyNotice(target.peer, target.contextToken);
|
|
219
|
+
});
|
|
243
220
|
}
|
|
244
221
|
|
|
245
222
|
return {
|
|
246
223
|
keepTyping,
|
|
247
224
|
typingOff,
|
|
248
225
|
pruneTyping() {
|
|
249
|
-
for (const peer of
|
|
250
|
-
if (!hasWork(peer))
|
|
226
|
+
for (const peer of typing.keys()) {
|
|
227
|
+
if (!hasWork(peer)) typing.stop(peer);
|
|
251
228
|
}
|
|
252
229
|
},
|
|
253
230
|
armAck,
|
|
@@ -258,13 +235,9 @@ export function createPresence(deps: PresenceDeps): Presence {
|
|
|
258
235
|
stopBusyNotice,
|
|
259
236
|
stopTyping,
|
|
260
237
|
reset() {
|
|
261
|
-
for (const cancel of typing.values()) cancel();
|
|
262
238
|
typing.clear();
|
|
263
|
-
for (const cancel of acks.values()) cancel();
|
|
264
239
|
acks.clear();
|
|
265
|
-
for (const cancel of busyTimers.values()) cancel();
|
|
266
240
|
busyTimers.clear();
|
|
267
|
-
for (const cancel of slowTimers.values()) cancel();
|
|
268
241
|
slowTimers.clear();
|
|
269
242
|
typingCache.clear();
|
|
270
243
|
ackedAt.clear();
|
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
* drive it.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import type { ArmTimer } from "./timer.ts";
|
|
15
|
+
|
|
14
16
|
export interface ReplyPipelineDeps {
|
|
15
17
|
/** Send one part to the peer. Parts arrive strictly in order. */
|
|
16
18
|
sendPart: (text: string) => Promise<void>;
|
|
17
19
|
/** Arm a one-shot timer; the returned function cancels it. */
|
|
18
|
-
armTimer:
|
|
20
|
+
armTimer: ArmTimer;
|
|
19
21
|
/** Flush a part that reaches this many characters. Default 400. */
|
|
20
22
|
partChars?: number;
|
|
21
23
|
/** Flush a part that has been quiet this long. Default 2000 ms. */
|
|
@@ -300,7 +300,7 @@ export class StatusScreenComponent implements Component {
|
|
|
300
300
|
private hints(view: StatusView): ScreenHint[] {
|
|
301
301
|
const hints: ScreenHint[] = ["Esc close"];
|
|
302
302
|
if (view.peerRows.length > 0) {
|
|
303
|
-
hints.push("j/k move", { text: "s stop", color: "
|
|
303
|
+
hints.push("j/k move", { text: "s stop", color: "warning" }, { text: "d reset", color: "warning" }, "h history");
|
|
304
304
|
}
|
|
305
305
|
hints.push(
|
|
306
306
|
{ text: "l login", color: "accent" },
|
|
@@ -18,6 +18,63 @@ export const defaultTimer: ArmTimer = (ms, fn) => {
|
|
|
18
18
|
return () => clearTimeout(timer);
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A set of one-shot timers keyed by a caller's id (a peer, a watcher).
|
|
23
|
+
*
|
|
24
|
+
* Every per-key timer in this extension follows the same protocol: cancel any
|
|
25
|
+
* pending entry, delete it, then act — and re-arming must replace, or a refresh
|
|
26
|
+
* loop would stack timers. Both rules live here so they are one implementation.
|
|
27
|
+
*/
|
|
28
|
+
export interface TimerMap {
|
|
29
|
+
/** Arm a timer for `key`, replacing a pending one. */
|
|
30
|
+
arm(key: string, ms: number, fn: () => void): void;
|
|
31
|
+
/** Whether a timer is pending for `key`. */
|
|
32
|
+
has(key: string): boolean;
|
|
33
|
+
/** Cancel and forget `key`'s timer. */
|
|
34
|
+
stop(key: string): void;
|
|
35
|
+
/** The keys with a pending timer. */
|
|
36
|
+
keys(): string[];
|
|
37
|
+
/** Cancel every pending timer and forget them. */
|
|
38
|
+
clear(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A {@link TimerMap} over the injected seam; the entries live in this closure. */
|
|
42
|
+
export function createTimerMap(arm: ArmTimer): TimerMap {
|
|
43
|
+
const pending = new Map<string, () => void>();
|
|
44
|
+
const map: TimerMap = {
|
|
45
|
+
arm(key, ms, fn) {
|
|
46
|
+
map.stop(key);
|
|
47
|
+
// The entry is gone before the callback runs, so a callback that re-arms
|
|
48
|
+
// itself (a keep-alive) or asks `has(key)` sees the truth rather than itself.
|
|
49
|
+
pending.set(
|
|
50
|
+
key,
|
|
51
|
+
arm(ms, () => {
|
|
52
|
+
pending.delete(key);
|
|
53
|
+
fn();
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
has(key) {
|
|
58
|
+
return pending.has(key);
|
|
59
|
+
},
|
|
60
|
+
stop(key) {
|
|
61
|
+
const cancel = pending.get(key);
|
|
62
|
+
if (cancel === undefined) return;
|
|
63
|
+
pending.delete(key);
|
|
64
|
+
cancel();
|
|
65
|
+
},
|
|
66
|
+
keys() {
|
|
67
|
+
return [...pending.keys()];
|
|
68
|
+
},
|
|
69
|
+
clear() {
|
|
70
|
+
// A copy: a canceller is free to touch the map it came from.
|
|
71
|
+
for (const cancel of [...pending.values()]) cancel();
|
|
72
|
+
pending.clear();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
return map;
|
|
76
|
+
}
|
|
77
|
+
|
|
21
78
|
/** An abortable sleep: resolves after `ms`, rejects when `signal` aborts. */
|
|
22
79
|
export function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
23
80
|
return new Promise((resolve, reject) => {
|