@gavin-hu/my-pi-agent 0.19.0 → 0.20.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 +17 -11
- package/README.zh-CN.md +13 -8
- package/docs/extensions/README.md +1 -1
- package/docs/extensions/board.md +286 -123
- package/docs/extensions/bytefly.md +381 -223
- package/docs/extensions/file-browse.md +14 -7
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +56 -11
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +45 -17
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +23 -5
- package/extensions/board/format.ts +107 -9
- package/extensions/board/join.ts +1 -1
- package/extensions/board/page.ts +170 -94
- package/extensions/board/result.ts +9 -0
- package/extensions/board/schema.ts +22 -7
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +1 -0
- package/extensions/board/tools.ts +186 -21
- package/extensions/board/transport/router.ts +27 -108
- package/extensions/board/transport/server.ts +18 -29
- package/extensions/board/types.ts +67 -0
- package/extensions/board/view/client.ts +23 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +5 -171
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +48 -88
- package/extensions/board/view/render.ts +213 -190
- package/extensions/board/view/styles.ts +65 -48
- package/extensions/board/view/team.ts +63 -41
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -8
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +117 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/panel/commands.ts +28 -1
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings.ts +72 -23
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- package/extensions/check/commands.ts +2 -1
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +8 -547
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/transport/router.ts +0 -2
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +6 -7
- package/extensions/file-browse/view/markdown.ts +11 -13
- package/extensions/file-browse/view/raw-html.ts +18 -13
- package/extensions/job/runtime.ts +4 -4
- package/extensions/plan/feedback.ts +40 -16
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +17 -1
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +31 -2
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +10 -1
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +89 -0
- package/extensions/plan/web-chrome.ts +3 -5
- package/extensions/plan/web-client.ts +179 -330
- package/extensions/plan/web-collab.ts +3 -3
- package/extensions/plan/web-comments.ts +18 -8
- package/extensions/plan/web-composer.ts +80 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +134 -0
- package/extensions/plan/web-outline.ts +192 -0
- package/extensions/plan/web-page.ts +4 -1
- package/extensions/plan/web-plan.ts +150 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +194 -0
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/session-bus/rows.ts +7 -3
- package/extensions/session-bus/runtime.ts +100 -38
- package/extensions/session-bus/store.ts +14 -2
- package/extensions/session-bus/tui.ts +1 -1
- package/extensions/session-bus/types.ts +10 -1
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/team/commands.ts +41 -8
- package/extensions/team/format.ts +14 -0
- package/extensions/team/index.ts +2 -1
- package/extensions/team/tools.ts +30 -9
- package/extensions/team/types.ts +17 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/wechat/bridge.ts +43 -172
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/worktree/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +15 -285
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +6 -5
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +86 -32
- package/lib/board/round-text.ts +11 -2
- package/lib/board/round.ts +25 -20
- package/lib/board/rules.ts +144 -11
- package/lib/board/store.ts +239 -52
- package/lib/board/view.ts +94 -10
- package/lib/format.ts +22 -0
- package/lib/http/serve.ts +3 -0
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +11 -0
- package/lib/policy.ts +14 -1
- package/lib/session-roster.ts +74 -3
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/glyphs.ts +2 -3
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +123 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +88 -1
- package/lib/web/names.ts +42 -0
- package/package.json +1 -1
- package/extensions/board/view/actions.ts +0 -306
- package/extensions/board/writes.ts +0 -99
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/job/process.ts +0 -35
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* The WeChat bridge: one connection per account, one session per peer.
|
|
3
3
|
*
|
|
4
4
|
* This is the composition root. The poll loop, outbound delivery, the typing and
|
|
5
|
-
* wait notices, admission (dedupe/cap/budget),
|
|
6
|
-
* their own module; the bridge wires them to the live
|
|
7
|
-
* and state, and owns the connection lifecycle and the
|
|
8
|
-
* admitted message is handed to the sender's own session
|
|
9
|
-
* worker, so a WeChat turn never runs in the terminal's session.
|
|
5
|
+
* wait notices, admission (dedupe/cap/budget), inbound media, and the inbound
|
|
6
|
+
* path itself each live in their own module; the bridge wires them to the live
|
|
7
|
+
* client, account, cursor, and state, and owns the connection lifecycle and the
|
|
8
|
+
* status snapshot. Each admitted message is handed to the sender's own session
|
|
9
|
+
* through the peer worker, so a WeChat turn never runs in the terminal's session.
|
|
10
10
|
*
|
|
11
11
|
* The peer only has the chat window, so the bridge carries the signals the phone
|
|
12
12
|
* needs: typing starts as soon as a message is picked up, a message waiting on
|
|
@@ -22,46 +22,24 @@ import { createDeliverer, type MessageTarget } from "./transport/deliver.ts";
|
|
|
22
22
|
import { createInboundAdmission } from "./transport/inbound.ts";
|
|
23
23
|
import { createPollLoop, defaultPollTimeout } from "./transport/poll.ts";
|
|
24
24
|
import { messageOf } from "./errors.ts";
|
|
25
|
-
import {
|
|
25
|
+
import { peerKey, sanitizeInbound, type PeerLanguage } from "./text/format.ts";
|
|
26
26
|
import { notice } from "./text/notices.ts";
|
|
27
27
|
import { createPresence } from "./peer/presence.ts";
|
|
28
|
+
import { createForgetToken, createIntake, createLanguageOf, type Counter } from "./intake.ts";
|
|
29
|
+
|
|
30
|
+
// The admission predicates, re-exported: they are the bridge's contract, and a caller
|
|
31
|
+
// should not have to know which module inside it answers them.
|
|
32
|
+
export { isAllowedPeer, isInboundMessage } from "./intake.ts";
|
|
28
33
|
import { createPeerChannels, type PeerChannels } from "./dialog/channels.ts";
|
|
29
34
|
import type { PendingPromptInfo, WechatInteractionChannel } from "./dialog/interaction.ts";
|
|
30
35
|
import { defaultSleep, defaultTimer, type ArmTimer } from "./timer.ts";
|
|
31
36
|
import { createPeerWorker, type PeerState, type PeerWorker } from "./peer/peer-worker.ts";
|
|
32
37
|
import type { PeerSession } from "./peer/peer-session.ts";
|
|
33
|
-
import { emptyState,
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
findSlashCommand,
|
|
37
|
-
parseSlash,
|
|
38
|
-
slashControls,
|
|
39
|
-
SLASH_COMMANDS,
|
|
40
|
-
type SlashCommand,
|
|
41
|
-
type SlashContext,
|
|
42
|
-
} from "./commands/slash.ts";
|
|
43
|
-
import type { BotCredentials, InboundItem, WechatState, WeixinMessage } from "./types.ts";
|
|
38
|
+
import { emptyState, ownerTarget } from "./state/state.ts";
|
|
39
|
+
import { SLASH_COMMANDS, type SlashCommand } from "./commands/slash.ts";
|
|
40
|
+
import type { BotCredentials, WechatState } from "./types.ts";
|
|
44
41
|
import type { WechatConfig } from "./config.ts";
|
|
45
42
|
|
|
46
|
-
/** Inbound messages held while a turn runs; beyond this the peer is told to wait. */
|
|
47
|
-
const MAX_QUEUE = 8;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Whether `peer` may drive the agent: the owner, or an explicit extra. Either id
|
|
51
|
-
* form is accepted (with or without `@im.wechat`), so a label copied from the
|
|
52
|
-
* control screen is not silently refused.
|
|
53
|
-
*/
|
|
54
|
-
export function isAllowedPeer(peer: string, owner: string | undefined, allowedPeers: string[]): boolean {
|
|
55
|
-
const key = peerKey(peer);
|
|
56
|
-
if (owner !== undefined && peerKey(owner) === key) return true;
|
|
57
|
-
return allowedPeers.some((allowed) => peerKey(allowed) === key);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** True when a message is inbound from a human (not the bot's own echo). */
|
|
61
|
-
export function isInboundMessage(message: WeixinMessage): message is WeixinMessage & { from_user_id: string } {
|
|
62
|
-
return message.message_type !== 2 && typeof message.from_user_id === "string" && message.from_user_id !== "";
|
|
63
|
-
}
|
|
64
|
-
|
|
65
43
|
/** The bridge's connection state, published to the UI for the status chip. */
|
|
66
44
|
export type BridgeState = "stopped" | "running" | "expired" | "paused";
|
|
67
45
|
|
|
@@ -181,7 +159,8 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
181
159
|
let client: WechatClient | undefined;
|
|
182
160
|
let account: BotCredentials | undefined;
|
|
183
161
|
let state: WechatState = { cursor: "", peers: {} };
|
|
184
|
-
|
|
162
|
+
/** Senders refused since the connection opened; the inbound path bumps it. */
|
|
163
|
+
const refused: Counter = { value: 0 };
|
|
185
164
|
/** Labels of refused senders, so the operator can allowlist one by name. */
|
|
186
165
|
const refusedPeers = new Set<string>();
|
|
187
166
|
/** The language each peer last wrote in, so a media-only message can answer. */
|
|
@@ -189,6 +168,9 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
189
168
|
/** Peers already told that the queue is full, until it drains. */
|
|
190
169
|
const overflow = new Set<string>();
|
|
191
170
|
const slashCommands = deps.slashCommands ?? SLASH_COMMANDS;
|
|
171
|
+
/** The peer's language memory: written here when a turn settles, read on every message. */
|
|
172
|
+
const languageOf = createLanguageOf(languages);
|
|
173
|
+
const forgetToken = createForgetToken({ state: () => state, saveState: deps.saveState });
|
|
192
174
|
|
|
193
175
|
/** One worker per open connection: it owns the queues and the peer sessions. */
|
|
194
176
|
let worker: PeerWorker | undefined;
|
|
@@ -214,14 +196,6 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
214
196
|
notify("WeChat session expired. Run /wechat login to reconnect.", "warning");
|
|
215
197
|
}
|
|
216
198
|
|
|
217
|
-
/** Drop a peer's cached reply token, so the next inbound message replaces it. */
|
|
218
|
-
function forgetToken(peer: string): void {
|
|
219
|
-
const entry = findPeer(state, peer);
|
|
220
|
-
if (!entry?.lastContextToken) return;
|
|
221
|
-
delete entry.lastContextToken;
|
|
222
|
-
deps.saveState(state);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
199
|
/** Send one bubble to a peer and report whether it was delivered. */
|
|
226
200
|
async function reply(target: MessageTarget, text: string, signal?: AbortSignal): Promise<boolean> {
|
|
227
201
|
return deliverer.reply(target, text, signal ?? abort?.signal);
|
|
@@ -244,35 +218,6 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
244
218
|
lastSendError = undefined;
|
|
245
219
|
},
|
|
246
220
|
});
|
|
247
|
-
/** Run a slash action out-of-band and reply to the sender; never starts a turn. */
|
|
248
|
-
async function runSlashAction(command: SlashCommand, target: MessageTarget, args: string): Promise<void> {
|
|
249
|
-
if (!command.run) return;
|
|
250
|
-
const ctx: SlashContext = {
|
|
251
|
-
args,
|
|
252
|
-
peer: target.peer,
|
|
253
|
-
reply: async (text) => {
|
|
254
|
-
await reply(target, text);
|
|
255
|
-
},
|
|
256
|
-
activeTurn: worker?.state(target.peer).status === "running",
|
|
257
|
-
language: languageOf(target.peer),
|
|
258
|
-
session: slashControls(target.peer, {
|
|
259
|
-
state: (peer) => worker?.state(peer) ?? { status: "idle", queued: 0 },
|
|
260
|
-
prompt: (peer) => channels.pendingFor(peer)?.title,
|
|
261
|
-
reset: (peer) => {
|
|
262
|
-
worker?.reset(peer);
|
|
263
|
-
},
|
|
264
|
-
stop: (peer) => worker?.abort(peer) ?? false,
|
|
265
|
-
history: (peer, count) => deps.readHistory(peer, count),
|
|
266
|
-
}),
|
|
267
|
-
};
|
|
268
|
-
try {
|
|
269
|
-
await command.run(ctx);
|
|
270
|
-
} catch (error) {
|
|
271
|
-
notify(`WeChat command /${command.name} failed: ${messageOf(error)}`, "warning");
|
|
272
|
-
await reply(target, `Command /${command.name} failed.`);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
221
|
/**
|
|
277
222
|
* Every peer's dialogs, one channel each.
|
|
278
223
|
*
|
|
@@ -298,15 +243,6 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
298
243
|
armTimer: deps.armTimer,
|
|
299
244
|
});
|
|
300
245
|
|
|
301
|
-
/** The peer's language: from this text, else the last one they wrote. */
|
|
302
|
-
function languageOf(peer: string, text?: string): PeerLanguage {
|
|
303
|
-
// A Chinese message is evidence of Chinese. Its absence is not evidence of
|
|
304
|
-
// English, or a short reply ("ok", "1") would flip an established conversation
|
|
305
|
-
// back to English; English is the fallback, so it needs no record of its own.
|
|
306
|
-
if (text && peerLanguage(text) === "zh") languages.set(peer, "zh");
|
|
307
|
-
return languages.get(peer) ?? "en";
|
|
308
|
-
}
|
|
309
|
-
|
|
310
246
|
/** Typing, acknowledgement, and the slow/busy lines; one timer registry each. */
|
|
311
247
|
const presence = createPresence({
|
|
312
248
|
typingIndicator: () => deps.config.typingIndicator,
|
|
@@ -339,92 +275,27 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
339
275
|
const budget = createTurnBudget(deps.config.maxTurnsPerHour, deps.now);
|
|
340
276
|
const admission = createAdmissionQueue({ admit: inbound.admit, notify });
|
|
341
277
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (typeof messageId === "number" && messageId !== 0 && seenMessage(state, peer, messageId)) return;
|
|
364
|
-
|
|
365
|
-
let text = rawText;
|
|
366
|
-
const cap = deps.config.maxInboundChars;
|
|
367
|
-
if (text.length > cap) {
|
|
368
|
-
const total = text.length;
|
|
369
|
-
text = `${text.slice(0, cap)}\n[WeChat message truncated: showing ${cap} of ${total} characters]`;
|
|
370
|
-
notify(`WeChat message truncated to ${cap} characters.`, "warning");
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const now = deps.now();
|
|
374
|
-
recordInbound(state, peer, message.context_token, now);
|
|
375
|
-
if (typeof messageId === "number" && messageId !== 0) rememberMessage(state, peer, messageId, now);
|
|
376
|
-
deps.saveState(state);
|
|
377
|
-
|
|
378
|
-
const target = { peer, contextToken: message.context_token ?? findPeer(state, peer)?.lastContextToken };
|
|
379
|
-
// A command is not an answer: while a dialog waits the model is blocked on it,
|
|
380
|
-
// so /stop and /status must reach their handlers or the peer's only way out is
|
|
381
|
-
// the prompt timing out. A message with media is never a command, and an
|
|
382
|
-
// unknown command falls through to the model as ordinary text.
|
|
383
|
-
const slash = media.length === 0 ? parseSlash(text) : undefined;
|
|
384
|
-
const command = slash ? findSlashCommand(slashCommands, slash.name) : undefined;
|
|
385
|
-
if (command && slash) {
|
|
386
|
-
if (command.ownerOnly && peerKey(peer) !== peerKey(owner ?? "")) {
|
|
387
|
-
refused += 1;
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
if (command.kind === "snippet") {
|
|
391
|
-
text = expandSnippet(command, slash.args);
|
|
392
|
-
} else {
|
|
393
|
-
void runSlashAction(command, target, slash.args);
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
// A reply to a pending prompt is consumed here, before queueing, so it never
|
|
398
|
-
// starts a turn of its own. A duplicate answer was stopped by the id check.
|
|
399
|
-
if (channels.handleInbound(peer, text)) return;
|
|
400
|
-
const language = languageOf(peer, text);
|
|
401
|
-
const item: InboundItem = { ...target, text, media, language };
|
|
402
|
-
if (!text && media.length === 0) {
|
|
403
|
-
// Nothing to read. A voice note without a transcript or a video is
|
|
404
|
-
// answered, so the peer knows it did not reach the agent.
|
|
405
|
-
if (unsupported > 0) void reply(item, notice("unsupported", language));
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
// A slot is reserved here, before the download, so a burst of media cannot
|
|
409
|
-
// all pass the check and then enqueue together.
|
|
410
|
-
if (!budget.allow(peer)) {
|
|
411
|
-
void reply(item, notice("budget", language));
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
const busy = (worker?.state(peer).queued ?? 0) + admission.pending(peer);
|
|
415
|
-
if (busy >= MAX_QUEUE) {
|
|
416
|
-
// Refuse rather than grow without bound; one notice per episode, not per
|
|
417
|
-
// dropped message.
|
|
418
|
-
if (!overflow.has(peer)) {
|
|
419
|
-
overflow.add(peer);
|
|
420
|
-
void reply(item, notice("overflow", language));
|
|
421
|
-
}
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
presence.keepTyping(peer, item.contextToken);
|
|
425
|
-
presence.armAck(item);
|
|
426
|
-
admission.enqueue(item);
|
|
427
|
-
}
|
|
278
|
+
/** The inbound path: admission, commands, and the hand-off to a peer's session. */
|
|
279
|
+
const intake = createIntake({
|
|
280
|
+
state: () => state,
|
|
281
|
+
saveState: deps.saveState,
|
|
282
|
+
account: () => account,
|
|
283
|
+
config: deps.config,
|
|
284
|
+
now: deps.now,
|
|
285
|
+
notify,
|
|
286
|
+
worker: () => worker,
|
|
287
|
+
channels,
|
|
288
|
+
presence,
|
|
289
|
+
budget,
|
|
290
|
+
admission,
|
|
291
|
+
languageOf,
|
|
292
|
+
reply: (target, text) => reply(target, text),
|
|
293
|
+
readHistory: deps.readHistory,
|
|
294
|
+
refused,
|
|
295
|
+
refusedPeers,
|
|
296
|
+
overflow,
|
|
297
|
+
slashCommands,
|
|
298
|
+
});
|
|
428
299
|
|
|
429
300
|
/** The long-poll loop; it reads the live client, cursor, and state. */
|
|
430
301
|
const poll = createPollLoop({
|
|
@@ -440,7 +311,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
440
311
|
notify,
|
|
441
312
|
onExpired: handleExpired,
|
|
442
313
|
onMessages: (messages) => {
|
|
443
|
-
for (const message of messages) handleMessage(message);
|
|
314
|
+
for (const message of messages) intake.handleMessage(message);
|
|
444
315
|
},
|
|
445
316
|
refreshLock: deps.refreshLock,
|
|
446
317
|
isRunning: () => running,
|
|
@@ -461,7 +332,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
461
332
|
function resetEphemeral(): void {
|
|
462
333
|
languages.clear();
|
|
463
334
|
overflow.clear();
|
|
464
|
-
refused = 0;
|
|
335
|
+
refused.value = 0;
|
|
465
336
|
refusedPeers.clear();
|
|
466
337
|
channels.reset();
|
|
467
338
|
deliverer.reset();
|
|
@@ -665,7 +536,7 @@ export function createBridge(deps: BridgeDeps): Bridge {
|
|
|
665
536
|
peerCount: Object.keys(state.peers).length,
|
|
666
537
|
queued: states.reduce((total, entry) => total + entry.state.queued, 0),
|
|
667
538
|
running: states.some((entry) => entry.state.status === "running"),
|
|
668
|
-
refused,
|
|
539
|
+
refused: refused.value,
|
|
669
540
|
refusedPeers: [...refusedPeers],
|
|
670
541
|
mediaDropped: inbound.dropped(),
|
|
671
542
|
promptPending: channels.hasPending(),
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound messages: admission, the slash commands a message may carry, and the hand-off to
|
|
3
|
+
* the sender's own session.
|
|
4
|
+
*
|
|
5
|
+
* This is the path a message takes from the poll loop to a turn, and it is the one part of
|
|
6
|
+
* the bridge that is a *sequence of decisions* rather than wiring: who is refused, what is
|
|
7
|
+
* a replay, what is a command, what is an answer to a pending prompt, and what is queued or
|
|
8
|
+
* turned away. The bridge keeps the connection lifecycle and the status snapshot; it hands
|
|
9
|
+
* this module the live state it reads, and nothing here touches the client or the lock.
|
|
10
|
+
*
|
|
11
|
+
* Every dependency whose value changes across a connection is a function, because a
|
|
12
|
+
* reconnect replaces the account, the state, and the worker rather than mutating them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AdmissionQueue, TurnBudget } from "./peer/admission.ts";
|
|
16
|
+
import type { PeerWorker } from "./peer/peer-worker.ts";
|
|
17
|
+
import type { Presence } from "./peer/presence.ts";
|
|
18
|
+
import type { PeerChannels } from "./dialog/channels.ts";
|
|
19
|
+
import { messageOf } from "./errors.ts";
|
|
20
|
+
import { findPeer, recordInbound, rememberMessage, seenMessage } from "./state/state.ts";
|
|
21
|
+
import { parseInbound, peerKey, peerLabel, peerLanguage, type PeerLanguage } from "./text/format.ts";
|
|
22
|
+
import { notice } from "./text/notices.ts";
|
|
23
|
+
import {
|
|
24
|
+
expandSnippet,
|
|
25
|
+
findSlashCommand,
|
|
26
|
+
parseSlash,
|
|
27
|
+
slashControls,
|
|
28
|
+
type SlashCommand,
|
|
29
|
+
type SlashContext,
|
|
30
|
+
} from "./commands/slash.ts";
|
|
31
|
+
import type { BotCredentials, InboundItem, WechatState, WeixinMessage } from "./types.ts";
|
|
32
|
+
import type { MessageTarget } from "./transport/deliver.ts";
|
|
33
|
+
import type { WechatConfig } from "./config.ts";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether `peer` may drive the agent: the owner, or an explicit extra. Either id
|
|
37
|
+
* form is accepted (with or without `@im.wechat`), so a label copied from the
|
|
38
|
+
* control screen is not silently refused.
|
|
39
|
+
*/
|
|
40
|
+
export function isAllowedPeer(peer: string, owner: string | undefined, allowedPeers: string[]): boolean {
|
|
41
|
+
const key = peerKey(peer);
|
|
42
|
+
if (owner !== undefined && peerKey(owner) === key) return true;
|
|
43
|
+
return allowedPeers.some((allowed) => peerKey(allowed) === key);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True when a message is inbound from a human (not the bot's own echo). */
|
|
47
|
+
export function isInboundMessage(message: WeixinMessage): message is WeixinMessage & { from_user_id: string } {
|
|
48
|
+
return message.message_type !== 2 && typeof message.from_user_id === "string" && message.from_user_id !== "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Inbound messages held while a turn runs; beyond this the peer is told to wait. */
|
|
52
|
+
export const MAX_QUEUE = 8;
|
|
53
|
+
|
|
54
|
+
/** Senders refused since the connection opened, capped so a spam run cannot fill the list. */
|
|
55
|
+
const REFUSED_LABEL_CAP = 5;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The peer's language: from this text, else the last one they wrote.
|
|
59
|
+
*
|
|
60
|
+
* A Chinese message is evidence of Chinese. Its absence is not evidence of English, or a
|
|
61
|
+
* short reply ("ok", "1") would flip an established conversation back; English is the
|
|
62
|
+
* fallback, so it needs no record of its own.
|
|
63
|
+
*/
|
|
64
|
+
export function createLanguageOf(languages: Map<string, PeerLanguage>): (peer: string, text?: string) => PeerLanguage {
|
|
65
|
+
return (peer, text) => {
|
|
66
|
+
if (text && peerLanguage(text) === "zh") languages.set(peer, "zh");
|
|
67
|
+
return languages.get(peer) ?? "en";
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Drop a peer's cached reply token, so the next inbound message replaces it. */
|
|
72
|
+
export function createForgetToken(deps: {
|
|
73
|
+
state: () => WechatState;
|
|
74
|
+
saveState: (state: WechatState) => void;
|
|
75
|
+
}): (peer: string) => void {
|
|
76
|
+
return (peer) => {
|
|
77
|
+
const state = deps.state();
|
|
78
|
+
const entry = findPeer(state, peer);
|
|
79
|
+
if (!entry?.lastContextToken) return;
|
|
80
|
+
delete entry.lastContextToken;
|
|
81
|
+
deps.saveState(state);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A counter two modules share: this one bumps it, the status snapshot reads it. */
|
|
86
|
+
export interface Counter {
|
|
87
|
+
value: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Everything the inbound path reads, as the bridge hands it in. */
|
|
91
|
+
export interface IntakeDeps {
|
|
92
|
+
/** The live state: replaced when a re-login changes the owner. */
|
|
93
|
+
state: () => WechatState;
|
|
94
|
+
saveState: (state: WechatState) => void;
|
|
95
|
+
/** The live account, for the owner a message is compared against. */
|
|
96
|
+
account: () => BotCredentials | undefined;
|
|
97
|
+
config: WechatConfig;
|
|
98
|
+
now: () => number;
|
|
99
|
+
notify: (message: string, kind?: "info" | "warning" | "error") => void;
|
|
100
|
+
/** The live worker: a connect or disconnect replaces it. */
|
|
101
|
+
worker: () => PeerWorker | undefined;
|
|
102
|
+
channels: PeerChannels;
|
|
103
|
+
presence: Presence;
|
|
104
|
+
budget: TurnBudget;
|
|
105
|
+
admission: AdmissionQueue;
|
|
106
|
+
languageOf: (peer: string, text?: string) => PeerLanguage;
|
|
107
|
+
/** Send one bubble to a peer for the bridge's own notices. */
|
|
108
|
+
reply: (target: MessageTarget, text: string) => Promise<boolean>;
|
|
109
|
+
readHistory: (peer: string, count: number) => Promise<string>;
|
|
110
|
+
/** Senders refused since the connection opened, and their labels. */
|
|
111
|
+
refused: Counter;
|
|
112
|
+
refusedPeers: Set<string>;
|
|
113
|
+
/** Peers already told the queue is full, until it drains. */
|
|
114
|
+
overflow: Set<string>;
|
|
115
|
+
slashCommands: readonly SlashCommand[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The inbound path: one function per message, with the command runner and the language
|
|
120
|
+
* lookup it needs beside it.
|
|
121
|
+
*/
|
|
122
|
+
export function createIntake(deps: IntakeDeps): { handleMessage: (message: WeixinMessage) => void } {
|
|
123
|
+
const { config, channels, presence, budget, admission, languageOf, notify } = deps;
|
|
124
|
+
|
|
125
|
+
/** Run a slash action out-of-band and reply to the sender; never starts a turn. */
|
|
126
|
+
async function runSlashAction(command: SlashCommand, target: MessageTarget, args: string): Promise<void> {
|
|
127
|
+
if (!command.run) return;
|
|
128
|
+
const worker = deps.worker();
|
|
129
|
+
const ctx: SlashContext = {
|
|
130
|
+
args,
|
|
131
|
+
peer: target.peer,
|
|
132
|
+
reply: async (text) => {
|
|
133
|
+
await deps.reply(target, text);
|
|
134
|
+
},
|
|
135
|
+
activeTurn: worker?.state(target.peer).status === "running",
|
|
136
|
+
language: languageOf(target.peer),
|
|
137
|
+
session: slashControls(target.peer, {
|
|
138
|
+
state: (peer) => worker?.state(peer) ?? { status: "idle", queued: 0 },
|
|
139
|
+
prompt: (peer) => channels.pendingFor(peer)?.title,
|
|
140
|
+
reset: (peer) => {
|
|
141
|
+
worker?.reset(peer);
|
|
142
|
+
},
|
|
143
|
+
stop: (peer) => worker?.abort(peer) ?? false,
|
|
144
|
+
history: (peer, count) => deps.readHistory(peer, count),
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
await command.run(ctx);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
notify(`WeChat command /${command.name} failed: ${messageOf(error)}`, "warning");
|
|
151
|
+
await deps.reply(target, `Command /${command.name} failed.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function handleMessage(message: WeixinMessage): void {
|
|
156
|
+
if (!isInboundMessage(message)) return;
|
|
157
|
+
const state = deps.state();
|
|
158
|
+
const worker = deps.worker();
|
|
159
|
+
const peer = message.from_user_id;
|
|
160
|
+
const { text: rawText, media, unsupported } = parseInbound(message.item_list);
|
|
161
|
+
const owner = state.ownerId ?? deps.account()?.ilinkUserId;
|
|
162
|
+
// Admission comes first: a refused sender never reaches the state file, a
|
|
163
|
+
// pending prompt, or the queue.
|
|
164
|
+
if (!isAllowedPeer(peer, owner, config.allowedPeers)) {
|
|
165
|
+
deps.refused.value += 1;
|
|
166
|
+
const label = peerLabel(peer);
|
|
167
|
+
// Name a new sender once, so the operator can allowlist it from the
|
|
168
|
+
// terminal; the cap keeps a spam run from filling the list.
|
|
169
|
+
if (deps.refusedPeers.size < REFUSED_LABEL_CAP && !deps.refusedPeers.has(label)) {
|
|
170
|
+
deps.refusedPeers.add(label);
|
|
171
|
+
notify(`WeChat refused a message from ${label}. Add it to allowedPeers to admit.`, "warning");
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// A server replay of an already handled message must not answer twice. The
|
|
176
|
+
// id is persisted, so a reload or reconnect cannot resurrect it.
|
|
177
|
+
const messageId = message.message_id;
|
|
178
|
+
if (typeof messageId === "number" && messageId !== 0 && seenMessage(state, peer, messageId)) return;
|
|
179
|
+
|
|
180
|
+
let text = rawText;
|
|
181
|
+
const cap = config.maxInboundChars;
|
|
182
|
+
if (text.length > cap) {
|
|
183
|
+
const total = text.length;
|
|
184
|
+
text = `${text.slice(0, cap)}\n[WeChat message truncated: showing ${cap} of ${total} characters]`;
|
|
185
|
+
notify(`WeChat message truncated to ${cap} characters.`, "warning");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const now = deps.now();
|
|
189
|
+
recordInbound(state, peer, message.context_token, now);
|
|
190
|
+
if (typeof messageId === "number" && messageId !== 0) rememberMessage(state, peer, messageId, now);
|
|
191
|
+
deps.saveState(state);
|
|
192
|
+
|
|
193
|
+
const target = { peer, contextToken: message.context_token ?? findPeer(state, peer)?.lastContextToken };
|
|
194
|
+
// A command is not an answer: while a dialog waits the model is blocked on it,
|
|
195
|
+
// so /stop and /status must reach their handlers or the peer's only way out is
|
|
196
|
+
// the prompt timing out. A message with media is never a command, and an
|
|
197
|
+
// unknown command falls through to the model as ordinary text.
|
|
198
|
+
const slash = media.length === 0 ? parseSlash(text) : undefined;
|
|
199
|
+
const command = slash ? findSlashCommand(deps.slashCommands, slash.name) : undefined;
|
|
200
|
+
if (command && slash) {
|
|
201
|
+
if (command.ownerOnly && peerKey(peer) !== peerKey(owner ?? "")) {
|
|
202
|
+
deps.refused.value += 1;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (command.kind === "snippet") {
|
|
206
|
+
text = expandSnippet(command, slash.args);
|
|
207
|
+
} else {
|
|
208
|
+
void runSlashAction(command, target, slash.args);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// A reply to a pending prompt is consumed here, before queueing, so it never
|
|
213
|
+
// starts a turn of its own. A duplicate answer was stopped by the id check.
|
|
214
|
+
if (channels.handleInbound(peer, text)) return;
|
|
215
|
+
const language = languageOf(peer, text);
|
|
216
|
+
const item: InboundItem = { ...target, text, media, language };
|
|
217
|
+
if (!text && media.length === 0) {
|
|
218
|
+
// Nothing to read. A voice note without a transcript or a video is
|
|
219
|
+
// answered, so the peer knows it did not reach the agent.
|
|
220
|
+
if (unsupported > 0) void deps.reply(item, notice("unsupported", language));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// A slot is reserved here, before the download, so a burst of media cannot
|
|
224
|
+
// all pass the check and then enqueue together.
|
|
225
|
+
if (!budget.allow(peer)) {
|
|
226
|
+
void deps.reply(item, notice("budget", language));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const busy = (worker?.state(peer).queued ?? 0) + admission.pending(peer);
|
|
230
|
+
if (busy >= MAX_QUEUE) {
|
|
231
|
+
// Refuse rather than grow without bound; one notice per episode, not per
|
|
232
|
+
// dropped message.
|
|
233
|
+
if (!deps.overflow.has(peer)) {
|
|
234
|
+
deps.overflow.add(peer);
|
|
235
|
+
void deps.reply(item, notice("overflow", language));
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
presence.keepTyping(peer, item.contextToken);
|
|
240
|
+
presence.armAck(item);
|
|
241
|
+
admission.enqueue(item);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { handleMessage };
|
|
245
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { messageOf } from "../../lib/errors.ts";
|
|
19
|
+
import { planModeRefusal } from "../../lib/plan-entry.ts";
|
|
19
20
|
import { isPlanModeEnabled } from "../../lib/plan-state.ts";
|
|
20
21
|
import { withRailsSuppressed } from "../../lib/rails.ts";
|
|
21
22
|
import {
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
formatWorktreeStatus,
|
|
25
26
|
isWorktreeCancelled,
|
|
26
27
|
pruneWorktrees,
|
|
27
|
-
|
|
28
|
+
enteredWorktree,
|
|
28
29
|
worktreeSnapshot,
|
|
29
30
|
} from "./lifecycle.ts";
|
|
30
31
|
import { type WorktreeAction, WorktreeListComponent } from "./screen.ts";
|
|
@@ -46,36 +47,42 @@ const USAGE = "Usage: /worktree [enter [name]|exit|prune]";
|
|
|
46
47
|
/** Refuse a lifecycle change while plan mode is read-only, as `/check` does. */
|
|
47
48
|
function refuseInPlanMode(ctx: ExtensionContext): boolean {
|
|
48
49
|
if (!isPlanModeEnabled(ctx.sessionManager.getBranch())) return false;
|
|
49
|
-
ctx.ui.notify("
|
|
50
|
+
ctx.ui.notify(planModeRefusal("changing worktrees"), "warning");
|
|
50
51
|
return true;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Run one lifecycle verb and report it the way a command does.
|
|
56
|
+
*
|
|
57
|
+
* Enter and exit differ in what they call and in the sentence a success prints, never in
|
|
58
|
+
* how a refusal is reported: a cancellation is the choice the user just made and reads as
|
|
59
|
+
* info, and everything else is a failure that names the verb that failed.
|
|
60
|
+
*/
|
|
61
|
+
async function reportLifecycle(ctx: ExtensionContext, verb: string, run: () => Promise<string>): Promise<void> {
|
|
55
62
|
try {
|
|
56
|
-
|
|
57
|
-
ctx.ui.notify(`Entered worktree ${worktreeLabel(state)}\n${state.path}`, "info");
|
|
63
|
+
ctx.ui.notify(await run(), "info");
|
|
58
64
|
} catch (error) {
|
|
59
65
|
if (isWorktreeCancelled(error)) {
|
|
60
66
|
ctx.ui.notify(messageOf(error), "info");
|
|
61
67
|
return;
|
|
62
68
|
}
|
|
63
|
-
ctx.ui.notify(
|
|
69
|
+
ctx.ui.notify(`${verb} failed: ${messageOf(error)}`, "error");
|
|
64
70
|
}
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
async function runEnter(pi: ExtensionAPI, ctx: ExtensionContext, name: string | undefined): Promise<void> {
|
|
74
|
+
if (refuseInPlanMode(ctx)) return;
|
|
75
|
+
// The path on its own line: a command's answer is read without a tool result beside it,
|
|
76
|
+
// and "which directory am I in now" is the question entering answers.
|
|
77
|
+
await reportLifecycle(ctx, "Enter", async () => {
|
|
78
|
+
const { state } = await enterWorktree(pi, ctx, { name });
|
|
79
|
+
return `${enteredWorktree(state)}\n${state.path}`;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
67
83
|
async function runExit(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
68
84
|
if (refuseInPlanMode(ctx)) return;
|
|
69
|
-
|
|
70
|
-
const result = await exitWorktree(pi, ctx, {});
|
|
71
|
-
ctx.ui.notify(result.output, "info");
|
|
72
|
-
} catch (error) {
|
|
73
|
-
if (isWorktreeCancelled(error)) {
|
|
74
|
-
ctx.ui.notify(messageOf(error), "info");
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
ctx.ui.notify(`Exit failed: ${messageOf(error)}`, "error");
|
|
78
|
-
}
|
|
85
|
+
await reportLifecycle(ctx, "Exit", async () => (await exitWorktree(pi, ctx, {})).output);
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
async function runPrune(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
|
|
@@ -36,13 +36,17 @@ export interface ExitOptions {
|
|
|
36
36
|
* must not fail because the default branch could not be read.
|
|
37
37
|
*/
|
|
38
38
|
async function describeHandoff(pi: ExtensionAPI, state: WorktreeState, recorded: number): Promise<Handoff> {
|
|
39
|
-
|
|
39
|
+
// `recorded` is the count `inspectWork` took against the commit the worktree was created
|
|
40
|
+
// at, read while the directory was still there; without that commit there is no fork
|
|
41
|
+
// point, and the count is unknown rather than zero.
|
|
42
|
+
const commits = state.baseCommit ? recorded : undefined;
|
|
43
|
+
if (!state.branch) return { branch: null, base: null, ahead: recorded, commits, merged: false };
|
|
40
44
|
try {
|
|
41
45
|
// `origin/HEAD` names the merge target when the repository has a remote; a
|
|
42
46
|
// local-only repository has none, and the main checkout's own branch is the
|
|
43
47
|
// branch a merge would actually land on.
|
|
44
48
|
const base = (await defaultBranch(pi, state.repoRoot)) ?? (await currentBranch(pi, state.repoRoot)) ?? null;
|
|
45
|
-
if (!base) return { branch: state.branch, base: null, ahead: recorded, merged: false };
|
|
49
|
+
if (!base) return { branch: state.branch, base: null, ahead: recorded, commits, merged: false };
|
|
46
50
|
// Count against the resolved base branch, not the commit recorded at entry:
|
|
47
51
|
// a worktree branch rebased onto a newer local branch would otherwise report
|
|
48
52
|
// every commit the base gained since, as its own.
|
|
@@ -55,15 +59,17 @@ async function describeHandoff(pi: ExtensionAPI, state: WorktreeState, recorded:
|
|
|
55
59
|
: (await refExists(pi, root, `origin/${base}`))
|
|
56
60
|
? `origin/${base}`
|
|
57
61
|
: null;
|
|
58
|
-
if (!ref) return { branch: state.branch, base: null, ahead: recorded, merged: false };
|
|
62
|
+
if (!ref) return { branch: state.branch, base: null, ahead: recorded, commits, merged: false };
|
|
63
|
+
const ahead = await commitsAheadOf(pi, root, state.branch, ref);
|
|
59
64
|
return {
|
|
60
65
|
branch: state.branch,
|
|
61
66
|
base,
|
|
62
|
-
ahead
|
|
63
|
-
|
|
67
|
+
ahead,
|
|
68
|
+
commits,
|
|
69
|
+
merged: ahead === 0 && commits !== 0 && (await isBranchMerged(pi, root, state.branch, ref)),
|
|
64
70
|
};
|
|
65
71
|
} catch {
|
|
66
|
-
return { branch: state.branch, base: null, ahead: recorded, merged: false };
|
|
72
|
+
return { branch: state.branch, base: null, ahead: recorded, commits, merged: false };
|
|
67
73
|
}
|
|
68
74
|
}
|
|
69
75
|
|