@gavin-hu/my-pi-agent 0.18.0 → 0.19.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 +52 -44
- package/README.zh-CN.md +46 -41
- package/docs/extensions/README.md +34 -0
- package/docs/extensions/board.md +344 -0
- package/{extensions/bytefly/README.md → docs/extensions/bytefly.md} +446 -404
- package/{extensions/check/README.md → docs/extensions/check.md} +83 -68
- package/{extensions/context/README.md → docs/extensions/context.md} +40 -23
- package/docs/extensions/file-browse.md +554 -0
- package/{extensions/file-open/README.md → docs/extensions/file-open.md} +31 -23
- package/{extensions/file-read/README.md → docs/extensions/file-read.md} +47 -55
- package/{extensions/goal/README.md → docs/extensions/goal.md} +115 -131
- package/{extensions/job/README.md → docs/extensions/job.md} +61 -50
- package/{extensions/memory/README.md → docs/extensions/memory.md} +71 -60
- package/{extensions/plan/README.md → docs/extensions/plan.md} +390 -317
- package/{extensions/question/README.md → docs/extensions/question.md} +54 -31
- package/{extensions/rewind/README.md → docs/extensions/rewind.md} +115 -122
- package/docs/extensions/session-bus.md +285 -0
- package/{extensions/subagent/README.md → docs/extensions/subagent.md} +165 -118
- package/docs/extensions/team.md +226 -0
- package/{extensions/todo/README.md → docs/extensions/todo.md} +85 -88
- package/{extensions/watch/README.md → docs/extensions/watch.md} +54 -36
- package/{extensions/web-access/README.md → docs/extensions/web-access.md} +93 -50
- package/{extensions/wechat/README.md → docs/extensions/wechat.md} +258 -162
- package/{extensions/worktree/README.md → docs/extensions/worktree.md} +115 -72
- package/extensions/board/commands.ts +87 -0
- package/extensions/board/config.ts +36 -0
- package/extensions/board/format.ts +72 -0
- package/extensions/board/index.ts +40 -0
- package/extensions/board/input.ts +54 -0
- package/extensions/board/join.ts +42 -0
- package/extensions/board/page.ts +312 -0
- package/extensions/board/result.ts +99 -0
- package/extensions/board/rows.ts +48 -0
- package/extensions/board/schema.ts +75 -0
- package/extensions/board/seat.ts +29 -0
- package/extensions/board/snapshot.ts +41 -0
- package/extensions/board/tools.ts +279 -0
- package/extensions/board/transport/router.ts +172 -0
- package/extensions/board/transport/server.ts +149 -0
- package/extensions/board/types.ts +54 -0
- package/extensions/board/view/actions.ts +306 -0
- package/extensions/board/view/client.ts +41 -0
- package/extensions/board/view/keys.ts +121 -0
- package/extensions/board/view/live.ts +397 -0
- package/extensions/board/view/page.ts +158 -0
- package/extensions/board/view/render.ts +496 -0
- package/extensions/board/view/styles.ts +211 -0
- package/extensions/board/view/team.ts +305 -0
- package/extensions/board/writes.ts +99 -0
- package/extensions/bytefly/panel/commands.ts +40 -7
- package/extensions/bytefly/panel/settings-runtime.ts +0 -6
- package/extensions/bytefly/panel/settings.ts +3 -20
- package/extensions/bytefly/panel/tui/settings-view.ts +1 -1
- package/extensions/bytefly/picker/discovery.ts +0 -2
- package/extensions/bytefly/picker/types.ts +0 -2
- package/extensions/bytefly/update/check.ts +39 -0
- package/extensions/bytefly/update/format.ts +15 -0
- package/extensions/bytefly/update/register.ts +73 -34
- package/extensions/bytefly/update/state.ts +6 -3
- package/extensions/context/tui.ts +1 -1
- package/extensions/file-browse/{config.ts → config/config.ts} +1 -1
- package/extensions/file-browse/{files.ts → files/files.ts} +1 -14
- package/extensions/file-browse/{paths.ts → files/paths.ts} +1 -1
- package/extensions/file-browse/{git.ts → git/git.ts} +10 -3
- package/extensions/file-browse/index.ts +4 -4
- package/extensions/file-browse/{live.ts → live/live.ts} +20 -69
- package/extensions/file-browse/{router.ts → transport/router.ts} +21 -15
- package/extensions/file-browse/transport/server.ts +110 -0
- package/extensions/file-browse/{chrome.ts → view/chrome.ts} +4 -4
- package/extensions/file-browse/{client.ts → view/client.ts} +1 -1
- package/extensions/file-browse/{diff-html.ts → view/diff-html.ts} +3 -3
- package/extensions/file-browse/{html.ts → view/html.ts} +14 -12
- package/extensions/file-browse/{icons.ts → view/icons.ts} +1 -1
- package/extensions/file-browse/{markdown.ts → view/markdown.ts} +7 -7
- package/extensions/file-browse/{raw-html.ts → view/raw-html.ts} +1 -1
- package/extensions/file-browse/{styles.ts → view/styles.ts} +29 -5
- package/extensions/file-browse/{tree.ts → view/tree.ts} +1 -1
- package/extensions/file-browse/{urls.ts → view/urls.ts} +1 -1
- package/extensions/file-read/schema.ts +2 -1
- package/extensions/memory/store.ts +12 -5
- package/extensions/plan/blocks.ts +23 -2
- package/extensions/plan/commands.ts +21 -16
- package/extensions/plan/feedback.ts +147 -38
- package/extensions/plan/index.ts +6 -4
- package/extensions/plan/plan-index.ts +43 -9
- package/extensions/plan/plan-text.ts +33 -23
- package/extensions/plan/plans.ts +8 -11
- package/extensions/plan/policy.ts +20 -12
- package/extensions/plan/review.ts +3 -27
- package/extensions/plan/runtime.ts +21 -9
- package/extensions/plan/sections.ts +112 -0
- package/extensions/plan/steps.ts +3 -22
- package/extensions/plan/tools/exit.ts +11 -95
- package/extensions/plan/tui.ts +5 -34
- package/extensions/plan/types.ts +0 -8
- package/extensions/plan/web-client.ts +1 -1
- package/extensions/plan/web-page.ts +0 -7
- package/extensions/plan/web-router.ts +3 -57
- package/extensions/plan/web-share-session.ts +24 -30
- package/extensions/plan/web-streams.ts +14 -14
- package/extensions/plan/web-tokens.ts +2 -2
- package/extensions/plan/web.ts +66 -120
- package/extensions/rewind/{commands.ts → commands/commands.ts} +5 -5
- package/extensions/rewind/{review.ts → commands/review.ts} +13 -13
- package/extensions/rewind/{rewind.ts → commands/rewind.ts} +6 -6
- package/extensions/rewind/{config.ts → config/config.ts} +1 -1
- package/extensions/rewind/index.ts +3 -3
- package/extensions/rewind/{policy.ts → state/policy.ts} +4 -4
- package/extensions/rewind/{runtime.ts → state/runtime.ts} +11 -11
- package/extensions/rewind/{snapshot-run.ts → state/snapshot-run.ts} +1 -1
- package/extensions/rewind/{timeline.ts → state/timeline.ts} +2 -2
- package/extensions/rewind/{format.ts → text/format.ts} +3 -3
- package/extensions/rewind/{git.ts → transport/git.ts} +2 -2
- package/extensions/rewind/{restore.ts → transport/restore.ts} +2 -2
- package/extensions/rewind/{snapshot.ts → transport/snapshot.ts} +1 -1
- package/extensions/rewind/{store.ts → transport/store.ts} +1 -1
- package/extensions/rewind/{checklist.ts → view/checklist.ts} +6 -6
- package/extensions/rewind/{hunk-picker.ts → view/hunk-picker.ts} +8 -8
- package/extensions/rewind/{path-picker.ts → view/path-picker.ts} +7 -7
- package/extensions/rewind/{review-tui.ts → view/review-tui.ts} +11 -11
- package/extensions/rewind/{rewind-tui.ts → view/rewind-tui.ts} +17 -11
- package/extensions/rewind/{status.ts → view/status.ts} +4 -4
- package/extensions/session-bus/commands.ts +17 -69
- package/extensions/session-bus/format.ts +22 -45
- package/extensions/session-bus/index.ts +13 -7
- package/extensions/session-bus/join-request.ts +40 -0
- package/extensions/session-bus/mail-watch.ts +134 -0
- package/extensions/session-bus/notice.ts +32 -4
- package/{lib/claims → extensions/session-bus}/overlap.ts +11 -18
- package/extensions/session-bus/protocol.ts +19 -49
- package/extensions/session-bus/resolve.ts +67 -0
- package/extensions/session-bus/result.ts +16 -51
- package/extensions/session-bus/rows.ts +4 -2
- package/extensions/session-bus/runtime.ts +129 -238
- package/extensions/session-bus/store.ts +42 -6
- package/extensions/session-bus/tools.ts +31 -66
- package/extensions/session-bus/tui.ts +4 -4
- package/extensions/session-bus/types.ts +13 -13
- package/extensions/subagent/agents.ts +9 -8
- package/extensions/subagent/invocation.ts +4 -2
- package/extensions/team/commands.ts +112 -0
- package/extensions/team/dispatch.ts +133 -0
- package/extensions/team/format.ts +105 -0
- package/extensions/team/index.ts +33 -0
- package/extensions/team/schema.ts +29 -0
- package/extensions/team/tools.ts +246 -0
- package/extensions/team/types.ts +48 -0
- package/extensions/web-access/fetch/page.ts +4 -1
- package/extensions/web-access/fetch/structured.ts +447 -0
- package/extensions/web-access/fetch/tool.ts +2 -1
- package/extensions/web-access/search/tool.ts +2 -1
- package/extensions/wechat/bridge.ts +76 -92
- package/extensions/wechat/{commands.ts → commands/commands.ts} +26 -18
- package/extensions/wechat/{doctor.ts → commands/doctor.ts} +5 -5
- package/extensions/wechat/{history.ts → commands/history.ts} +1 -1
- package/extensions/wechat/{login.ts → commands/login.ts} +3 -3
- package/extensions/wechat/{slash.ts → commands/slash.ts} +38 -2
- package/extensions/wechat/dialog/channels.ts +136 -0
- package/extensions/wechat/{interaction.ts → dialog/interaction.ts} +37 -37
- package/extensions/wechat/{prompt.ts → dialog/prompt.ts} +2 -2
- package/extensions/wechat/{remote-ui.ts → dialog/remote-ui.ts} +1 -1
- package/extensions/wechat/index.ts +10 -10
- package/extensions/wechat/{admission.ts → peer/admission.ts} +2 -2
- package/extensions/wechat/{peer-session.ts → peer/peer-session.ts} +2 -2
- package/extensions/wechat/{peer-worker.ts → peer/peer-worker.ts} +100 -22
- package/extensions/wechat/{presence.ts → peer/presence.ts} +8 -7
- package/extensions/wechat/{reply-pipeline.ts → peer/reply-pipeline.ts} +1 -1
- package/extensions/wechat/{credentials.ts → state/credentials.ts} +2 -2
- package/extensions/wechat/{state.ts → state/state.ts} +14 -2
- package/extensions/wechat/{format.ts → text/format.ts} +3 -3
- package/extensions/wechat/{notices.ts → text/notices.ts} +8 -0
- package/extensions/wechat/tools.ts +29 -12
- package/extensions/wechat/{client.ts → transport/client.ts} +26 -2
- package/extensions/wechat/{deliver.ts → transport/deliver.ts} +72 -28
- package/extensions/wechat/{inbound.ts → transport/inbound.ts} +5 -5
- package/extensions/wechat/{media.ts → transport/media.ts} +11 -5
- package/extensions/wechat/transport/outbound.ts +142 -0
- package/extensions/wechat/{poll.ts → transport/poll.ts} +3 -3
- package/extensions/wechat/types.ts +10 -1
- package/extensions/wechat/{login-screen.ts → view/login-screen.ts} +4 -4
- package/extensions/wechat/{status-screen.ts → view/status-screen.ts} +31 -26
- package/extensions/worktree/guard.ts +3 -5
- package/lib/README.md +8 -6
- package/lib/board/card.ts +290 -0
- package/lib/board/links.ts +117 -0
- package/lib/board/page-pointer.ts +256 -0
- package/lib/board/round-text.ts +72 -0
- package/lib/board/round.ts +89 -0
- package/lib/board/rules.ts +304 -0
- package/lib/board/session.ts +105 -0
- package/lib/board/store.ts +310 -0
- package/lib/board/view.ts +150 -0
- package/lib/errors.ts +12 -4
- package/lib/format.ts +13 -0
- package/lib/http/guard.ts +112 -0
- package/lib/http/index.ts +16 -5
- package/lib/http/serve.ts +234 -0
- package/lib/http/server.ts +12 -10
- package/lib/http/sink.ts +110 -0
- package/lib/http/token.ts +38 -0
- package/lib/policy.ts +12 -2
- package/lib/requirements.ts +6 -7
- package/lib/session-label.ts +64 -0
- package/lib/session-roster.ts +130 -0
- package/lib/session-tool.ts +184 -0
- package/lib/tool-names.ts +25 -2
- package/lib/tui/glyphs.ts +2 -0
- package/lib/web/README.md +5 -2
- package/lib/web/components.ts +5 -0
- package/lib/web/runtime.ts +7 -1
- package/lib/web/tokens.ts +25 -2
- package/package.json +26 -14
- package/skills/next/SKILL.md +4 -3
- package/skills/project-docs/SKILL.md +133 -0
- package/skills/project-docs/references/audit.md +131 -0
- package/skills/project-docs/references/changelog.md +105 -0
- package/skills/project-docs/references/discovery.md +127 -0
- package/skills/project-docs/references/readme.md +80 -0
- package/skills/project-docs/references/release.md +84 -0
- package/skills/project-docs/references/roadmap.md +93 -0
- package/skills/project-docs/scripts/state.ts +316 -0
- package/skills/refactor/SKILL.md +100 -125
- package/skills/refactor/references/handoff.md +35 -0
- package/skills/refactor/references/signals.md +54 -0
- package/skills/refactor/scripts/inventory.ts +587 -0
- package/extensions/file-browse/README.md +0 -506
- package/extensions/file-browse/server.ts +0 -141
- package/extensions/plan/split.ts +0 -87
- package/extensions/session-bus/README.md +0 -247
- package/extensions/session-bus/team.ts +0 -102
- package/extensions/wechat/outbound.ts +0 -100
- package/lib/plans/sections.ts +0 -172
- package/lib/plans/setting.ts +0 -52
- package/lib/plans/split.ts +0 -215
- package/lib/plans/store.ts +0 -19
- package/lib/plans/team.ts +0 -151
- package/skills/changelog/SKILL.md +0 -148
- package/skills/parallel-dev/SKILL.md +0 -150
- /package/extensions/file-browse/{rev.ts → live/rev.ts} +0 -0
- /package/extensions/file-browse/{diff.ts → text/diff.ts} +0 -0
- /package/extensions/file-browse/{fuzzy.ts → text/fuzzy.ts} +0 -0
- /package/extensions/file-browse/{live-client.ts → view/live-client.ts} +0 -0
- /package/extensions/file-browse/{palette.ts → view/palette.ts} +0 -0
- /package/extensions/rewind/{patch.ts → text/patch.ts} +0 -0
- /package/extensions/rewind/{paths.ts → text/paths.ts} +0 -0
- /package/extensions/rewind/{queue.ts → transport/queue.ts} +0 -0
- /package/extensions/rewind/{temp-index.ts → transport/temp-index.ts} +0 -0
- /package/extensions/wechat/{lock.ts → state/lock.ts} +0 -0
- /package/extensions/wechat/{channel.ts → text/channel.ts} +0 -0
package/extensions/plan/split.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The split at approval: the switch that enables it, the roster card, and the
|
|
3
|
-
* `## Team` section it writes.
|
|
4
|
-
*
|
|
5
|
-
* Plan mode keeps one job — the plan document and its approval gate — and this
|
|
6
|
-
* module is the part of that gate that concerns a team: whether the feature is on,
|
|
7
|
-
* what the user is told to run in the sessions this package cannot start, and the
|
|
8
|
-
* write that makes the team real. Everything mechanical about the split itself
|
|
9
|
-
* (which tables are legal, which paths collide) is `lib/plans/split.ts`, because
|
|
10
|
-
* the bus reads the same section.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { sanitizeLine, shortenPath } from "../../lib/format.ts";
|
|
14
|
-
import { splitEnabled } from "../../lib/plans/setting.ts";
|
|
15
|
-
import { checkSplit, mergeOrder } from "../../lib/plans/split.ts";
|
|
16
|
-
import type { PlanTeam, PlanUnit } from "../../lib/plans/team.ts";
|
|
17
|
-
|
|
18
|
-
export { SPLIT_OFF_NOTE, SPLIT_SETTING_ID, SPLIT_SETTING_LABEL, splitEnabled } from "../../lib/plans/setting.ts";
|
|
19
|
-
|
|
20
|
-
/** What approval decides about the units a model proposed. */
|
|
21
|
-
export type SplitDecision =
|
|
22
|
-
| { kind: "none" }
|
|
23
|
-
| { kind: "off" }
|
|
24
|
-
| { kind: "refused"; reason: string }
|
|
25
|
-
| { kind: "serial" }
|
|
26
|
-
| { kind: "team"; team: PlanTeam; verdict: "pair" | "team" };
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Decide what to do with the `units` an approval carried.
|
|
30
|
-
*
|
|
31
|
-
* The switch is checked first: with it off the units are ignored entirely rather
|
|
32
|
-
* than validated, so a user who never enabled the feature cannot be blocked by a
|
|
33
|
-
* rule about it.
|
|
34
|
-
*/
|
|
35
|
-
export function decideSplit(
|
|
36
|
-
cwd: string,
|
|
37
|
-
root: string | undefined,
|
|
38
|
-
units: readonly PlanUnit[] | undefined,
|
|
39
|
-
): SplitDecision {
|
|
40
|
-
if (units === undefined || units.length === 0) return { kind: "none" };
|
|
41
|
-
if (!splitEnabled(cwd, root)) return { kind: "off" };
|
|
42
|
-
const check = checkSplit(units);
|
|
43
|
-
if (!check.ok) return { kind: "refused", reason: check.reason };
|
|
44
|
-
if (check.verdict === "serial") return { kind: "serial" };
|
|
45
|
-
return { kind: "team", team: { lead: check.lead, units: check.units }, verdict: check.verdict };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* The roster card: what the user runs, in which session, and in what order.
|
|
50
|
-
*
|
|
51
|
-
* A lead cannot start, name, or prompt a peer session, so the card is the whole of
|
|
52
|
-
* what step 3 can do — and its first line says so, because the rest of the flow
|
|
53
|
-
* depends on the user believing it.
|
|
54
|
-
*/
|
|
55
|
-
export function rosterCard(team: PlanTeam, planPath: string, sessionLabel?: string): string {
|
|
56
|
-
const lead = team.units.find((unit) => unit.lead)?.name ?? team.lead;
|
|
57
|
-
const owners = team.units.filter((unit) => !unit.lead);
|
|
58
|
-
const rows = owners.map((unit) => {
|
|
59
|
-
const join = `session({action:"join", team:"${teamName(planPath)}", unit:"${unit.name}"})`;
|
|
60
|
-
return ` ${unit.name}: enter_worktree ${unit.name} · /sessions name ${unit.name} · /sessions watch wake\n ${join}`;
|
|
61
|
-
});
|
|
62
|
-
const order = mergeOrderLine(team);
|
|
63
|
-
// The lead's own label is the one line of the card it cannot get itself until
|
|
64
|
-
// after this card is printed, so the card either names it or says how to set it.
|
|
65
|
-
const name = sessionLabel?.trim();
|
|
66
|
-
const label = name
|
|
67
|
-
? ` your label: ${sanitizeLine(name)} — owners send their report there`
|
|
68
|
-
: ` your label: none — name this session: session({action:"name", label:"${lead}"})`;
|
|
69
|
-
return [
|
|
70
|
-
`Split: ${team.units.length} units · ${shortenPath(planPath)}`,
|
|
71
|
-
" You start and switch every session; nothing here can.",
|
|
72
|
-
...rows,
|
|
73
|
-
` ${order} · your unit: ${lead}`,
|
|
74
|
-
label,
|
|
75
|
-
].join("\n");
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** The team's name is the plan file's stem, so two plans never share one. */
|
|
79
|
-
export function teamName(planPath: string): string {
|
|
80
|
-
const name = planPath.split(/[\\/]/).pop() ?? planPath;
|
|
81
|
-
return name.replace(/\.md$/i, "");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Merge order in one line, from the dependency graph the validation accepted. */
|
|
85
|
-
export function mergeOrderLine(team: PlanTeam): string {
|
|
86
|
-
return `merge order: ${mergeOrder(team.units).join(" → ")}`;
|
|
87
|
-
}
|
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
# session-bus — coordinate parallel Pi sessions
|
|
2
|
-
|
|
3
|
-
A local coordination bus for Pi sessions on the same machine and repository.
|
|
4
|
-
Sessions discover each other, exchange short messages and events, consume an
|
|
5
|
-
inbox, and claim paths before editing. Delivery is pull-based, and a session can
|
|
6
|
-
arm an inbox watch to be told about new mail: a report at its next turn, or —
|
|
7
|
-
with `wake` — one raised turn as soon as the agent is idle, including right after
|
|
8
|
-
a running turn settles. No network service, and no hidden context.
|
|
9
|
-
|
|
10
|
-
<img src="../../assets/session.png" alt="The /sessions screen: a counts summary, session rows with unread and claim counts, and the focused session's cwd, claims, and identity" width="100%">
|
|
11
|
-
|
|
12
|
-
```bash
|
|
13
|
-
pi --extension ./extensions/session-bus # load just this extension
|
|
14
|
-
pi -e . # load the whole package
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
## What it does
|
|
18
|
-
|
|
19
|
-
- Registers one `session` tool and the `/sessions` command; in the TUI the command
|
|
20
|
-
opens a live board with Sessions, Inbox, and Claims scopes.
|
|
21
|
-
- Stores one record per session, one file per message, and one file per claim
|
|
22
|
-
under `<agent-dir>/session-bus/<repo-key>/`; the key is the repository’s common
|
|
23
|
-
git directory, so worktrees meet on one bus.
|
|
24
|
-
- A TUI session joins on `session_start`; RPC, JSON, print, and WeChat peer
|
|
25
|
-
sessions join only when the tool or `/sessions` is used. A subagent or a phone
|
|
26
|
-
peer does not appear merely because the package is loaded.
|
|
27
|
-
- A heartbeat keeps a joined session visible; a stale session is pruned after ten
|
|
28
|
-
minutes, its claims are released, and inbox messages expire after 24 hours.
|
|
29
|
-
- An armed inbox watch polls this session’s own inbox directory and queues what
|
|
30
|
-
arrived. It announces; `inbox` still consumes.
|
|
31
|
-
- Writes are atomic and private to the OS user; there is no server, no network,
|
|
32
|
-
and no cross-user authorization.
|
|
33
|
-
|
|
34
|
-
## Commands
|
|
35
|
-
|
|
36
|
-
| Command | Effect |
|
|
37
|
-
|---|---|
|
|
38
|
-
| `/sessions` | Open the board: `Tab` cycles Sessions, Inbox, and Claims. `Enter` opens the focused row, `w` arms or disarms this session's inbox watch, and in Claims `x` releases a claim you hold after a y/N confirmation. A non-TUI caller prints the listing and joins. |
|
|
39
|
-
| `/sessions name <label>` | Name this session for the bus and for Pi. The label is unique among active sessions, and it becomes the session name the footer shows and `/resume` lists. |
|
|
40
|
-
| `/sessions watch` | Report new mail at this session’s next turn. |
|
|
41
|
-
| `/sessions watch wake` | Report new mail, and raise one turn as soon as the agent is idle — including right after a running turn settles. |
|
|
42
|
-
| `/sessions watch off` | Stop the inbox watch. |
|
|
43
|
-
| `/sessions help` | Show the command usage. |
|
|
44
|
-
|
|
45
|
-
Labels are how another session addresses you. Name one by the outcome it owns
|
|
46
|
-
(`billing-core`, `integrator`, `verify-billing`), not by a layer or a role;
|
|
47
|
-
duplicate labels get a short id suffix automatically.
|
|
48
|
-
|
|
49
|
-
Naming also sets Pi's session name, so the footer's `[label]` and `/resume` agree
|
|
50
|
-
with the bus. Because the bus derives a joining session's label from that name, a
|
|
51
|
-
named session comes back under the same label after a resume.
|
|
52
|
-
|
|
53
|
-
The tool's `name` action is the same rename from the model's side, so a lead can
|
|
54
|
-
name itself once the roster card has told it how — the one line of the card it
|
|
55
|
-
cannot get any other way.
|
|
56
|
-
|
|
57
|
-
## Tools
|
|
58
|
-
|
|
59
|
-
| Action | Inputs | Result |
|
|
60
|
-
|---|---|---|
|
|
61
|
-
| `status` | — | Active sessions with unread counts, claims, and this session’s identity, plus `units n/m` for the newest split in this repository. Prunes stale records. |
|
|
62
|
-
| `send` | `to`, `text`, optional `kind`, optional `paths` | Writes one message per recipient. `to: "*"` is only valid with `kind: "event"`. |
|
|
63
|
-
| `inbox` | optional `limit` | Returns and deletes up to `limit` messages, oldest first (default 20, max 50). |
|
|
64
|
-
| `claim` | `paths` | Claims repository-relative files or directory prefixes; returns conflicts with their owner. |
|
|
65
|
-
| `release` | `paths` | Removes claims owned by this session; reports paths owned by someone else. |
|
|
66
|
-
| `join` | `team`, `unit` | Claims every path of one unit in an approved split. Needs the plan's `## Team` section, so it refuses a plan that was never split, and needs `plan.split` on. Idempotent: joining twice is the same claims. |
|
|
67
|
-
| `leave` | optional `team`, `unit` | Releases that unit's paths, or every claim this session holds when neither is given. Never gated by the switch. |
|
|
68
|
-
| `watch` | optional `wake`, optional `stop` | Arms this session’s inbox watch, or turns it off with `stop`. `wake: true` raises one turn per burst once the agent is idle; `wake: false` reports at the next turn; omitting it keeps an armed watch’s mode. |
|
|
69
|
-
| `name` | `label` | Renames this session on the bus and in Pi — the same path as `/sessions name`, with the same uniqueness rule, so the footer and `/resume` agree with the record peers address. An empty label is refused rather than keeping the old one, and a label a live session holds is refused with its owner named. The result reports the label the bus settled on. |
|
|
70
|
-
|
|
71
|
-
Message kinds are `message` (default), `event`, `ask`, and `handoff`. An `ask` is
|
|
72
|
-
still a message: it does not block or wait for a reply. Text is capped at 4,000
|
|
73
|
-
characters. Paths resolve against the caller’s working directory and are stored
|
|
74
|
-
relative to its repository root; a path outside the repository is refused.
|
|
75
|
-
|
|
76
|
-
A claim is all-or-nothing: if one path conflicts, nothing is claimed. A claim on
|
|
77
|
-
a directory conflicts with claims below it and above it, so `src/api` and
|
|
78
|
-
`src/api/routes.ts` cannot be held by different sessions. There is no `force`
|
|
79
|
-
takeover; wait for the owner, ask it to release, or let the session expire.
|
|
80
|
-
|
|
81
|
-
`wake` is the mode, not an arm-time switch: `/sessions watch wake` raises a turn
|
|
82
|
-
for a watch that was reporting at the next turn, and `/sessions watch` — or
|
|
83
|
-
`wake: false` — sets it back. The board’s `w` only arms and disarms, and it arms
|
|
84
|
-
a plain watch; set `wake` with the command or the tool. Nothing arms on
|
|
85
|
-
`session_start`; a session that never asks stays pull-only.
|
|
86
|
-
|
|
87
|
-
## Parallel workflow
|
|
88
|
-
|
|
89
|
-
1. The user names each session (`/sessions name`), and the contract (OpenAPI,
|
|
90
|
-
schema, or types) is written before parallel work starts.
|
|
91
|
-
2. When the plan was approved with a split, each session `join`s its unit once —
|
|
92
|
-
that claims every path the unit owns, so nobody types a path list by hand.
|
|
93
|
-
Otherwise each session claims its paths. Then it implements its side.
|
|
94
|
-
3. A contract, build, or test change goes out as an event:
|
|
95
|
-
`session({action:"send", to:"*", kind:"event", text:"contract v2: ..."})`.
|
|
96
|
-
4. Other sessions consume `inbox` at the start of their next turn, or arm
|
|
97
|
-
`session({action:"watch", wake:true})` so a message raises their turn as soon
|
|
98
|
-
as they are idle; a wake that arrives mid-turn is raised once that run
|
|
99
|
-
settles, never pushed into the running turn.
|
|
100
|
-
5. The lead merges the worktrees and runs the project’s full check.
|
|
101
|
-
|
|
102
|
-
The bus carries coordination, not authority. The plan, todo list, contract, and
|
|
103
|
-
git branch remain the source of truth.
|
|
104
|
-
|
|
105
|
-
### Joining a split
|
|
106
|
-
|
|
107
|
-
A plan approval can write a `## Team` table into the plan file, which is where a
|
|
108
|
-
joining session reads its unit from:
|
|
109
|
-
|
|
110
|
-
```text
|
|
111
|
-
session({action:"join", team:"2026-11-04-1802-billing", unit:"core"})
|
|
112
|
-
→ Joined 2026-11-04-1802-billing/core: claimed src/billing/core.
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
`team` is the plan file's stem, so two plans in one repository never share a team.
|
|
116
|
-
The unit's paths come from the table the same module wrote, so a hand-edited or
|
|
117
|
-
stale section is refused rather than half-read; an unknown unit names the units
|
|
118
|
-
that exist; a taken unit names its holder; and with `plan.split` off `join`
|
|
119
|
-
refuses and names the settings row. `status` reports `units 1/2 joined` with the
|
|
120
|
-
holder of each unit, which is how a lead answers "has everyone started?" without
|
|
121
|
-
messaging anyone.
|
|
122
|
-
|
|
123
|
-
A claim belongs to the live session, not to a branch: a session that exits
|
|
124
|
-
releases its unit, so a restarted owner joins again (the join is idempotent), and
|
|
125
|
-
a crashed one frees the unit when its record goes stale. That is why the roster
|
|
126
|
-
card asks you to name each session — an unnamed one joins under its working
|
|
127
|
-
directory's name, which says nothing about the unit it holds.
|
|
128
|
-
|
|
129
|
-
## Behaviour by mode
|
|
130
|
-
|
|
131
|
-
| Mode | Behaviour |
|
|
132
|
-
|---|---|
|
|
133
|
-
| `tui` | Joins automatically on `session_start`. `/sessions` opens the board: sessions, this session's unread mail, and every claim, with `Tab` between them. |
|
|
134
|
-
| `rpc`, `json`, `print` | Does not join automatically. The first `session` call or `/sessions` joins; this is how a subagent or an RPC-driven session opts in. |
|
|
135
|
-
| WeChat peer | Same as `rpc`: the peer is a separate AgentSession in the bridge process and joins only on explicit use. Messages are read by that peer’s model; the bridge does not forward them to the phone. |
|
|
136
|
-
|
|
137
|
-
Every mode can arm the inbox watch; the notice is a visible transcript message, so
|
|
138
|
-
a `wake` raises that session’s own turn, not a phone notification.
|
|
139
|
-
|
|
140
|
-
The TUI opens the board; RPC, JSON, and print print the compact listing. The board
|
|
141
|
-
changes only this session's own state: `w` toggles its watch and `x` releases a
|
|
142
|
-
claim it holds. Mail is previewed, not consumed — `session inbox` consumes it —
|
|
143
|
-
and no view shows another session's inbox or releases its claims.
|
|
144
|
-
|
|
145
|
-
## Configuration
|
|
146
|
-
|
|
147
|
-
None in v1. The heartbeat (10 s), watch poll (2 s), session TTL (10 min), message
|
|
148
|
-
TTL (24 h), text cap, inbox limit, and preview cap are constants in the extension.
|
|
149
|
-
The bus lives under the agent directory, not the project, so it is never committed.
|
|
150
|
-
|
|
151
|
-
## Limitations
|
|
152
|
-
|
|
153
|
-
- **Same machine, same OS user.** Any process with that user’s file access can
|
|
154
|
-
read or write the bus; there is no authentication between sessions.
|
|
155
|
-
- **Pull-based by default.** A message does not wake a session that did not arm
|
|
156
|
-
its inbox watch. Agents must call `inbox` at coordination points.
|
|
157
|
-
- **Arming is per session and session-only.** It survives neither a resume nor a
|
|
158
|
-
new process; unread mail is previewed once more after `/resume`.
|
|
159
|
-
- **The board acts on your own state only.** `w` arms or disarms this session's
|
|
160
|
-
inbox watch and `x` releases a claim it holds. Mail is a preview, not a consume:
|
|
161
|
-
the board never deletes a message, never marks one reported, and never touches a
|
|
162
|
-
peer's inbox or claim.
|
|
163
|
-
- **Two armed peers can ping-pong.** Each reply wakes the other as soon as it is
|
|
164
|
-
idle. A wake raises one turn per burst, and `inbox` consumes, so the loop needs
|
|
165
|
-
both sides to keep replying; the answer is to disarm one of them.
|
|
166
|
-
- **No broadcast history.** A broadcast reaches the sessions active at send time;
|
|
167
|
-
a session that joins later does not receive it.
|
|
168
|
-
- **No forced claims.** A crashed owner blocks its paths until the session TTL
|
|
169
|
-
expires. This is deliberate; stealing a claim is worse than waiting.
|
|
170
|
-
- **Bounded by TTL.** An idle session with a stopped heartbeat is pruned after
|
|
171
|
-
ten minutes; its record, its claims, and an inbox directory left empty go with
|
|
172
|
-
it, and its unread mail expires after 24 hours.
|
|
173
|
-
- **A message file that does not parse is dropped.** The board's read never
|
|
174
|
-
consumes mail, but it does delete a file it cannot validate, so a truncated
|
|
175
|
-
message cannot linger on the bus.
|
|
176
|
-
- **No network.** It is not a distributed bus and does not synchronize machines.
|
|
177
|
-
|
|
178
|
-
## Design notes
|
|
179
|
-
|
|
180
|
-
- **Common git dir as the key.** `git rev-parse --git-common-dir` gives all
|
|
181
|
-
worktrees of one repository the same bus; `--show-toplevel` normalizes claim
|
|
182
|
-
paths within each worktree. Non-repository sessions fall back to their cwd.
|
|
183
|
-
- **One path, one claim.** The cwd is canonicalized at join and every claim or
|
|
184
|
-
release is re-keyed by its real target, so a working directory reached through
|
|
185
|
-
a symlink still resolves inside its own repository, a link inside the repository
|
|
186
|
-
and the file it names are the same claim, and a link that leaves the repository
|
|
187
|
-
is refused. The automatic label is the real basename of the cwd, so a session
|
|
188
|
-
started through a link named differently is named after the directory it is
|
|
189
|
-
actually in. An absolute path argument that sits outside the repository is
|
|
190
|
-
refused before it is re-keyed, and a relative one whose target leaves the
|
|
191
|
-
repository is refused by the re-keying itself.
|
|
192
|
-
- **One file per item.** Sessions never append to a shared registry: each session
|
|
193
|
-
writes its own record, each message is its own file, and a claim is a temp file
|
|
194
|
-
hard-linked into place — an exclusive create no reader can see half-written.
|
|
195
|
-
This avoids a cross-process lock for the common path.
|
|
196
|
-
- **Lazy join.** Loading a package extension must not make every subagent and
|
|
197
|
-
WeChat peer a team member. Auto-join only in `tui`; every other mode opts in by
|
|
198
|
-
using the tool or command.
|
|
199
|
-
- **Sanitized at the boundary.** Messages are model-authored and are sanitized
|
|
200
|
-
before rendering, like other untrusted text in the transcript.
|
|
201
|
-
- **Poll, not `fs.watch`.** The inbox watch reads this session's inbox directory
|
|
202
|
-
with one `readdir` every 2 s, the same trade `watch` makes for a file tail, so
|
|
203
|
-
it does not depend on a platform's event API or a filename in the event. Only
|
|
204
|
-
an armed session polls.
|
|
205
|
-
- **Read the board, prune on the next call.** The open board polls a read-only
|
|
206
|
-
`snapshot()` and `peek()` that hide a session past the TTL, a claim held by one,
|
|
207
|
-
and consume nothing. The physical prune, and the claim release that goes with
|
|
208
|
-
it, happen on the next `status` or `claim` call — which is the call that reader
|
|
209
|
-
is about to make.
|
|
210
|
-
- **Release by the stored path.** A claim is stored relative to the repo root of
|
|
211
|
-
the session that took it, so releasing one resolves nothing: re-resolving it
|
|
212
|
-
against a cwd below that root would target `src/src/api`. The screen refuses a
|
|
213
|
-
peer's claim before asking the store, which checks the owner again.
|
|
214
|
-
- **A pane is a log or prose.** `DetailContents.text` wraps at the render width
|
|
215
|
-
and `tail: false` opens it at the top, so a message reads as prose while a job
|
|
216
|
-
log keeps following its tail.
|
|
217
|
-
- **The notice reuses the shared report channel.** `lib/report.ts` already owns
|
|
218
|
-
whether an outcome may raise a turn; the bus supplies the pending mail and the
|
|
219
|
-
text, and bytefly renders the notice like a `job` or `watch` one.
|
|
220
|
-
|
|
221
|
-
## Pi integration
|
|
222
|
-
|
|
223
|
-
| Point | Detail |
|
|
224
|
-
|---|---|
|
|
225
|
-
| Tools | `session` — one action-shaped tool; `readOnlyHint: false`, `destructiveHint: true`, `openWorldHint: false`, `executionMode: "sequential"`. |
|
|
226
|
-
| Commands | `/sessions` (board in `tui`, listing elsewhere), `/sessions name <label>`, `/sessions watch [off\|wake]`, `/sessions help`. |
|
|
227
|
-
| UI | `tui` mounts `SessionListComponent` through `withPolledScreen` (rails suppressed, a 1 s read-only poll of `snapshot()` + `peek()`); every other mode prints through `ctx.ui.notify`. No status chip, so the watch never changes the footer. |
|
|
228
|
-
| Events | `session_start` resolves the repository and joins a TUI session and arms the mail trigger; `before_agent_start` reports queued non-wake mail at the next turn, `agent_settled` retries a wake the run suppressed, and `context` prunes stale notices; `session_shutdown` clears the heartbeat, the watch ticker, and the record. The seven hooks are `lib/report.ts`'s `registerPendingChannel`, not a local copy. |
|
|
229
|
-
| State | Files under `<agent-dir>/session-bus/<repo-key>/`; nothing in the session branch. The armed watch is session-only. `/resume` re-registers the same session id. |
|
|
230
|
-
| Lifecycle | The factory only registers. The runtime starts on `session_start`; heartbeat, watch ticker, and cleanup belong to it. Disabling `session-bus` through `PI_DISABLED_EXTENSIONS` registers nothing. |
|
|
231
|
-
| Mode | Uses `ctx.mode` to decide auto-join and whether `/sessions` mounts the board; `askHuman` is not involved and no status chip is published. |
|
|
232
|
-
|
|
233
|
-
## File map
|
|
234
|
-
|
|
235
|
-
| File | Responsibility |
|
|
236
|
-
|---|---|
|
|
237
|
-
| `index.ts` | Disable guard, registration, the pending-report channel, and the session hooks. |
|
|
238
|
-
| `runtime.ts` | Lifecycle, repo key, lazy join, heartbeat, inbox watch and its poll, operations. |
|
|
239
|
-
| `store.ts` | Atomic JSON files, inbox, peek, claims, pruning. |
|
|
240
|
-
| `protocol.ts` | Pure labels, targets, paths, validation, conflict rules. |
|
|
241
|
-
| `tui.ts` | The `/sessions` board, its scopes, and the pane over one row; the row grammar is in `rows.ts`. |
|
|
242
|
-
| `rows.ts` | The session/claim/message row builders and summaries shared by the board and the transcript. |
|
|
243
|
-
| `result.ts` | The `session` tool's expanded result block and its collapsed line. |
|
|
244
|
-
| `notice.ts` | The mail notice's model text and renderer details. |
|
|
245
|
-
| `tools.ts` | The `session` tool and its renderer. |
|
|
246
|
-
| `commands.ts` | `/sessions`. |
|
|
247
|
-
| `format.ts` | Model-facing listing and result text. |
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reading a split out of the plan store, for a session that did not write it.
|
|
3
|
-
*
|
|
4
|
-
* The bus and plan mode never import each other, so the plan file is the
|
|
5
|
-
* contract: this module finds the newest plan that carries a `## Team` section,
|
|
6
|
-
* or the one a `join` named, and reads it through the shared parser. Everything
|
|
7
|
-
* here is a refusal with a reason — a hand-edited or stale section must never
|
|
8
|
-
* half-read, because a unit read wrong is a session claiming the wrong paths.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { shortenPath } from "../../lib/format.ts";
|
|
14
|
-
import { type PlanUnit, readTeam } from "../../lib/plans/team.ts";
|
|
15
|
-
|
|
16
|
-
/** One unit, with the team it belongs to. */
|
|
17
|
-
export interface JoinedUnit {
|
|
18
|
-
team: string;
|
|
19
|
-
name: string;
|
|
20
|
-
paths: string[];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** The team a `join` or a status line is about. */
|
|
24
|
-
export interface TeamRef {
|
|
25
|
-
team: string;
|
|
26
|
-
units: PlanUnit[];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Resolve a `team`/`unit` pair to the paths a join claims.
|
|
31
|
-
*
|
|
32
|
-
* `team` is the plan file's stem, which is why two plans in one repository never
|
|
33
|
-
* share a team: the stamp in the name is what keeps them apart.
|
|
34
|
-
*/
|
|
35
|
-
export function resolveUnit(plansRoot: string, input: { team?: string; unit?: string }, verb = "join"): JoinedUnit {
|
|
36
|
-
const team = input.team?.trim();
|
|
37
|
-
const wanted = input.unit?.trim();
|
|
38
|
-
if (!team) throw new Error(`${verb} needs a team: pass the plan file name without ".md".`);
|
|
39
|
-
if (!wanted) throw new Error(`${verb} needs a unit inside "${team}".`);
|
|
40
|
-
const file = join(plansRoot, `${team.replace(/[\\/]/g, "")}.md`);
|
|
41
|
-
let content: string;
|
|
42
|
-
try {
|
|
43
|
-
content = readFileSync(file, "utf-8");
|
|
44
|
-
} catch {
|
|
45
|
-
throw new Error(`No split plan named "${team}" in this repository's plan store (${shortenPath(plansRoot)}).`);
|
|
46
|
-
}
|
|
47
|
-
const parsed = readTeam(content);
|
|
48
|
-
if (parsed === undefined) {
|
|
49
|
-
throw new Error(`"${team}" has no team section; a split is written when a plan is approved with plan.split on.`);
|
|
50
|
-
}
|
|
51
|
-
if ("error" in parsed) throw new Error(`${team}: ${parsed.error}`);
|
|
52
|
-
const unit = parsed.units.find((entry) => entry.name === wanted);
|
|
53
|
-
if (!unit) {
|
|
54
|
-
const known = parsed.units.map((entry) => entry.name).join(", ");
|
|
55
|
-
throw new Error(`"${team}" has no unit "${wanted}"; it has ${known}.`);
|
|
56
|
-
}
|
|
57
|
-
return { team, name: unit.name, paths: [...unit.paths] };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** How many of the newest plans a status call reads before giving up on finding a split. */
|
|
61
|
-
const MAX_TEAM_SCAN = 20;
|
|
62
|
-
|
|
63
|
-
/** The newest plan in the store that carries a team, or undefined when none does. */
|
|
64
|
-
export function newestTeam(plansRoot: string): TeamRef | undefined {
|
|
65
|
-
let names: string[];
|
|
66
|
-
try {
|
|
67
|
-
names = readdirSync(plansRoot).filter((name) => name.toLowerCase().endsWith(".md") && !name.startsWith("."));
|
|
68
|
-
} catch {
|
|
69
|
-
return undefined;
|
|
70
|
-
}
|
|
71
|
-
const stamped = names
|
|
72
|
-
.map((name) => {
|
|
73
|
-
try {
|
|
74
|
-
return { name, at: statSync(join(plansRoot, name)).mtimeMs };
|
|
75
|
-
} catch {
|
|
76
|
-
return undefined;
|
|
77
|
-
}
|
|
78
|
-
})
|
|
79
|
-
.filter((entry): entry is { name: string; at: number } => entry !== undefined)
|
|
80
|
-
.sort((a, b) => b.at - a.at)
|
|
81
|
-
.slice(0, MAX_TEAM_SCAN);
|
|
82
|
-
for (const entry of stamped) {
|
|
83
|
-
try {
|
|
84
|
-
const team = readTeam(readFileSync(join(plansRoot, entry.name), "utf-8"));
|
|
85
|
-
if (team !== undefined && !("error" in team)) {
|
|
86
|
-
return { team: entry.name.replace(/\.md$/i, ""), units: team.units };
|
|
87
|
-
}
|
|
88
|
-
} catch {
|
|
89
|
-
// An unreadable plan is simply not the newest team.
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return undefined;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** The label holding any of `unit`'s paths, from the bus's claims. */
|
|
96
|
-
export function unitHolder(
|
|
97
|
-
unit: PlanUnit,
|
|
98
|
-
claims: readonly { path: string; ownerLabel: string }[],
|
|
99
|
-
conflicts: (a: string, b: string) => boolean,
|
|
100
|
-
): string | undefined {
|
|
101
|
-
return claims.find((claim) => unit.paths.some((path) => conflicts(claim.path, path)))?.ownerLabel;
|
|
102
|
-
}
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Outbound file sending: encrypt a local file and deliver it as one Weixin file
|
|
3
|
-
* message.
|
|
4
|
-
*
|
|
5
|
-
* The flow mirrors the reference client: read the plaintext, compute its size and
|
|
6
|
-
* MD5, generate a 16-byte AES key and a file key, request the CDN upload target,
|
|
7
|
-
* upload the AES-128-ECB ciphertext, then send a `type: 4` file item whose media
|
|
8
|
-
* reference carries the returned download parameter.
|
|
9
|
-
*
|
|
10
|
-
* Everything is injected, so it is tested with a fake client and a temp file.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
14
|
-
import { readFileSync } from "node:fs";
|
|
15
|
-
import { basename } from "node:path";
|
|
16
|
-
import { rejectionOf, type WechatClient } from "./client.ts";
|
|
17
|
-
import { aesEcbPaddedSize, encryptEcb, sanitizeFileName, uploadUrlOf } from "./media.ts";
|
|
18
|
-
import type { BotCredentials, CDNMedia } from "./types.ts";
|
|
19
|
-
|
|
20
|
-
/** `UploadMediaType.FILE` from the protocol. */
|
|
21
|
-
export const UPLOAD_MEDIA_TYPE_FILE = 3;
|
|
22
|
-
|
|
23
|
-
export interface SendLocalFileArgs {
|
|
24
|
-
client: WechatClient;
|
|
25
|
-
account: BotCredentials;
|
|
26
|
-
cdnBaseUrl: string;
|
|
27
|
-
/** Files larger than this are refused. */
|
|
28
|
-
maxBytes: number;
|
|
29
|
-
to: string;
|
|
30
|
-
contextToken?: string;
|
|
31
|
-
filePath: string;
|
|
32
|
-
/** Display name; defaults to the file's base name, sanitized. */
|
|
33
|
-
fileName?: string;
|
|
34
|
-
signal?: AbortSignal;
|
|
35
|
-
/** Random bytes for the AES key and file key; defaults to `crypto.randomBytes`. */
|
|
36
|
-
random?: (size: number) => Buffer;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Upload and send a local file. Returns true when the message was accepted.
|
|
41
|
-
* Any failure (unreadable file, size cap, upload rejection) returns false rather
|
|
42
|
-
* than throwing, so the caller can fall back.
|
|
43
|
-
*/
|
|
44
|
-
export async function sendLocalFile(args: SendLocalFileArgs): Promise<boolean> {
|
|
45
|
-
try {
|
|
46
|
-
const plain = readFileSync(args.filePath);
|
|
47
|
-
if (plain.length === 0 || plain.length > args.maxBytes) return false;
|
|
48
|
-
|
|
49
|
-
const random = args.random ?? randomBytes;
|
|
50
|
-
const aesKey = random(16);
|
|
51
|
-
const filekey = random(16).toString("hex");
|
|
52
|
-
const rawsize = plain.length;
|
|
53
|
-
const rawfilemd5 = createHash("md5").update(plain).digest("hex");
|
|
54
|
-
const filesize = aesEcbPaddedSize(rawsize);
|
|
55
|
-
|
|
56
|
-
const upload = await args.client.getUploadUrl(
|
|
57
|
-
args.account,
|
|
58
|
-
{
|
|
59
|
-
filekey,
|
|
60
|
-
mediaType: UPLOAD_MEDIA_TYPE_FILE,
|
|
61
|
-
toUserId: args.to,
|
|
62
|
-
rawsize,
|
|
63
|
-
rawfilemd5,
|
|
64
|
-
filesize,
|
|
65
|
-
aeskey: aesKey.toString("hex"),
|
|
66
|
-
},
|
|
67
|
-
args.signal,
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
const downloadParam = await args.client.uploadCdn(
|
|
71
|
-
uploadUrlOf(upload, args.cdnBaseUrl, filekey),
|
|
72
|
-
encryptEcb(plain, aesKey),
|
|
73
|
-
args.signal,
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
// The reference encodes the hex key string as base64 (not the raw 16 bytes);
|
|
77
|
-
// the download decoder accepts either.
|
|
78
|
-
const media: CDNMedia = {
|
|
79
|
-
encrypt_query_param: downloadParam,
|
|
80
|
-
aes_key: Buffer.from(aesKey.toString("hex")).toString("base64"),
|
|
81
|
-
encrypt_type: 1,
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
const response = await args.client.sendFileMessage(
|
|
85
|
-
args.account,
|
|
86
|
-
{
|
|
87
|
-
to: args.to,
|
|
88
|
-
fileName: sanitizeFileName(args.fileName ?? basename(args.filePath)),
|
|
89
|
-
len: String(rawsize),
|
|
90
|
-
media,
|
|
91
|
-
contextToken: args.contextToken,
|
|
92
|
-
},
|
|
93
|
-
args.signal,
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
return rejectionOf(response) === undefined;
|
|
97
|
-
} catch {
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
}
|