@nanhara/hara 0.89.0 → 0.98.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/CHANGELOG.md +74 -0
- package/README.md +4 -1
- package/dist/agent/loop.js +113 -8
- package/dist/config.js +51 -9
- package/dist/gateway/serve.js +58 -1
- package/dist/gateway/signal.js +220 -0
- package/dist/gateway/tmux-routes.js +135 -0
- package/dist/gateway/wecom.js +383 -0
- package/dist/index.js +1021 -82
- package/dist/org-fleet/enroll.js +28 -5
- package/dist/plugins/plugins.js +49 -1
- package/dist/profile/profile.js +436 -0
- package/dist/providers/anthropic.js +28 -1
- package/dist/providers/openai.js +16 -1
- package/dist/security/guardian.js +261 -0
- package/dist/session/session-model.js +36 -0
- package/dist/statusbar.js +12 -3
- package/dist/tools/ask_user.js +64 -0
- package/dist/tools/external_agent.js +118 -0
- package/dist/tools/skill.js +6 -2
- package/dist/tools/todo.js +65 -8
- package/dist/tui/App.js +280 -33
- package/dist/tui/run.js +36 -1
- package/package.json +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Signal adapter for `hara gateway` — talks to a LOCAL signal-cli daemon running in HTTP/JSON-RPC mode
|
|
2
|
+
// (built-in fetch, zero new dep; no WebSocket, no cloud API). The user installs signal-cli, registers/links the
|
|
3
|
+
// bot's phone number, and runs `signal-cli -a <number> daemon --http localhost:8080`. Inbound is drained via the
|
|
4
|
+
// JSON-RPC `receive` method on a long-poll loop (the robust zero-dep path — no SSE line-buffering quirks);
|
|
5
|
+
// outbound + attachment fetch go through JSON-RPC `send`/`getAttachment`. Creds from HARA_SIGNAL_RPC_URL
|
|
6
|
+
// (e.g. http://localhost:8080) + HARA_SIGNAL_NUMBER (the bot's registered phone, E.164). Same ChatAdapter shape
|
|
7
|
+
// as the others, so all cross-platform gateway plumbing (send_file, system context, stuck-guard, image
|
|
8
|
+
// attach/describe) works unchanged. Mirrors the Matrix long-poll model + the Discord download-media/sendFile
|
|
9
|
+
// patterns, and the hermes signal.py protocol/redaction behavior.
|
|
10
|
+
//
|
|
11
|
+
// EXTERNAL DEPENDENCY: signal-cli is NOT bundled — it's a separate program the user installs and runs (it is the
|
|
12
|
+
// only way to speak Signal's protocol; there is no official cloud API). The adapter just speaks HTTP/JSON-RPC to
|
|
13
|
+
// the daemon the user has running. See setup notes at the bottom of this file.
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
15
|
+
import { join, basename } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { chunkText } from "./telegram.js";
|
|
18
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
// E.164 phone numbers (+15551234567) anywhere in a string → +155****4567 (mirrors hermes' _redact_phone).
|
|
20
|
+
const PHONE_RE = /\+[1-9]\d{6,14}/g;
|
|
21
|
+
/** Redact phone numbers for logging. Keeps a 4/4 head+tail window; never logs the full E.164. (pure) */
|
|
22
|
+
export function redactPhone(s) {
|
|
23
|
+
if (!s)
|
|
24
|
+
return "<none>";
|
|
25
|
+
return s.replace(PHONE_RE, (p) => (p.length <= 8 ? "****" : `${p.slice(0, 4)}****${p.slice(-4)}`));
|
|
26
|
+
}
|
|
27
|
+
const isImageExt = (ext) => /^\.(png|jpe?g|gif|webp)$/i.test(ext);
|
|
28
|
+
/** Guess a file extension from an attachment's contentType / filename (signal-cli gives us both as hints). (pure) */
|
|
29
|
+
export function attachmentExt(contentType, filename) {
|
|
30
|
+
const ct = (contentType ?? "").toLowerCase();
|
|
31
|
+
if (ct.startsWith("image/")) {
|
|
32
|
+
if (ct.includes("png"))
|
|
33
|
+
return ".png";
|
|
34
|
+
if (ct.includes("jpeg") || ct.includes("jpg"))
|
|
35
|
+
return ".jpg";
|
|
36
|
+
if (ct.includes("gif"))
|
|
37
|
+
return ".gif";
|
|
38
|
+
if (ct.includes("webp"))
|
|
39
|
+
return ".webp";
|
|
40
|
+
}
|
|
41
|
+
const fromName = (filename ?? "").toLowerCase().match(/\.(png|jpe?g|gif|webp|mp4|mp3|ogg|m4a|pdf|txt)$/);
|
|
42
|
+
if (fromName)
|
|
43
|
+
return fromName[0];
|
|
44
|
+
if (ct.startsWith("audio/"))
|
|
45
|
+
return ".ogg";
|
|
46
|
+
if (ct.startsWith("video/"))
|
|
47
|
+
return ".mp4";
|
|
48
|
+
return ".bin";
|
|
49
|
+
}
|
|
50
|
+
/** Sniff a file extension from magic bytes — fallback when signal-cli's base64 attachment lacks a contentType. (pure) */
|
|
51
|
+
export function extFromBytes(data) {
|
|
52
|
+
if (data.length >= 4 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47)
|
|
53
|
+
return ".png";
|
|
54
|
+
if (data.length >= 2 && data[0] === 0xff && data[1] === 0xd8)
|
|
55
|
+
return ".jpg";
|
|
56
|
+
if (data.length >= 4 && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38)
|
|
57
|
+
return ".gif";
|
|
58
|
+
if (data.length >= 12 && data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50)
|
|
59
|
+
return ".webp";
|
|
60
|
+
return ".bin";
|
|
61
|
+
}
|
|
62
|
+
/** Parse one signal-cli envelope → InboundMsg + the image attachments to fetch (pure; the fetch/download happens in
|
|
63
|
+
* start()). Mirrors hermes' _handle_envelope: unwraps the {envelope:{...}} shape, handles plain dataMessage + edits,
|
|
64
|
+
* filters our own outbound + stories. Filtering of OUR OWN number is done here via `selfNumber`. Returns the msg +
|
|
65
|
+
* the image attachment refs (only images — other media is ignored, like Telegram/Matrix). null = skip. */
|
|
66
|
+
export function parseSignalMessage(raw, selfNumber) {
|
|
67
|
+
if (!raw || typeof raw !== "object")
|
|
68
|
+
return null;
|
|
69
|
+
const env = raw.envelope ?? raw; // signal-cli wraps payloads as { envelope: {...} }
|
|
70
|
+
// Stories / typing / receipts / sync echoes carry no inbound dataMessage → skip. Also skip our own outbound
|
|
71
|
+
// (syncMessage is the echo of what WE sent from another linked device — never treat it as user input).
|
|
72
|
+
if (env.syncMessage)
|
|
73
|
+
return null;
|
|
74
|
+
if (env.storyMessage)
|
|
75
|
+
return null;
|
|
76
|
+
if (env.typingMessage || env.receiptMessage)
|
|
77
|
+
return null;
|
|
78
|
+
const sender = env.sourceNumber || env.sourceUuid || env.source;
|
|
79
|
+
if (!sender)
|
|
80
|
+
return null;
|
|
81
|
+
if (selfNumber && (env.sourceNumber === selfNumber || env.source === selfNumber))
|
|
82
|
+
return null; // our own message
|
|
83
|
+
// editMessage carries its updated dataMessage nested inside (mirrors hermes).
|
|
84
|
+
const data = env.dataMessage ?? env.editMessage?.dataMessage;
|
|
85
|
+
if (!data || typeof data !== "object")
|
|
86
|
+
return null;
|
|
87
|
+
const group = data.groupInfo;
|
|
88
|
+
const groupId = group?.groupId;
|
|
89
|
+
const chatId = groupId ? `group:${groupId}` : String(sender);
|
|
90
|
+
const atts = Array.isArray(data.attachments) ? data.attachments : [];
|
|
91
|
+
const images = atts
|
|
92
|
+
.filter((a) => a?.id && (String(a.contentType ?? "").startsWith("image/") || isImageExt(attachmentExt(a?.contentType, a?.filename))))
|
|
93
|
+
.map((a) => ({ id: String(a.id), contentType: a?.contentType, filename: a?.filename }));
|
|
94
|
+
const text = typeof data.message === "string" ? data.message : "";
|
|
95
|
+
if (!text && images.length === 0)
|
|
96
|
+
return null; // no text + no image (reaction/sticker/other media) → skip
|
|
97
|
+
return {
|
|
98
|
+
msg: {
|
|
99
|
+
chatId,
|
|
100
|
+
userId: String(sender),
|
|
101
|
+
userName: env.sourceName || String(sender),
|
|
102
|
+
text: text || "[图片]",
|
|
103
|
+
},
|
|
104
|
+
images,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export function signalAdapter(rpcUrl, selfNumber) {
|
|
108
|
+
const base = rpcUrl.replace(/\/+$/, ""); // trim trailing slashes
|
|
109
|
+
let rpcSeq = 0;
|
|
110
|
+
/** One JSON-RPC 2.0 call to the signal-cli daemon. Returns `result` (any) or null on error. */
|
|
111
|
+
async function rpc(method, params, signal) {
|
|
112
|
+
const id = `${method}_${++rpcSeq}`;
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetch(`${base}/api/v1/rpc`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: { "content-type": "application/json" },
|
|
117
|
+
body: JSON.stringify({ jsonrpc: "2.0", method, params, id }),
|
|
118
|
+
signal,
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok)
|
|
121
|
+
return null;
|
|
122
|
+
const j = (await res.json());
|
|
123
|
+
if (j.error) {
|
|
124
|
+
console.error(`hara gateway[signal]: RPC ${method} error:`, redactPhone(JSON.stringify(j.error)));
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return j.result ?? null;
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
if (signal?.aborted)
|
|
131
|
+
return null;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Recipient/group routing shared by send + sendFile (mirrors hermes). */
|
|
136
|
+
const target = (chatId) => {
|
|
137
|
+
const id = String(chatId);
|
|
138
|
+
return id.startsWith("group:") ? { groupId: id.slice(6) } : { recipient: [id] };
|
|
139
|
+
};
|
|
140
|
+
/** Fetch a signal-cli attachment by id (base64 over JSON-RPC) → a local path under ~/.hara/signal/media. */
|
|
141
|
+
async function downloadAttachment(ref) {
|
|
142
|
+
try {
|
|
143
|
+
const result = await rpc("getAttachment", { account: selfNumber, id: ref.id });
|
|
144
|
+
// signal-cli returns either a raw base64 string or { data: "base64..." }
|
|
145
|
+
const b64 = typeof result === "string" ? result : result && typeof result === "object" ? result.data : null;
|
|
146
|
+
if (!b64 || typeof b64 !== "string")
|
|
147
|
+
return null;
|
|
148
|
+
const bytes = Buffer.from(b64, "base64");
|
|
149
|
+
let ext = attachmentExt(ref.contentType, ref.filename);
|
|
150
|
+
if (ext === ".bin")
|
|
151
|
+
ext = extFromBytes(bytes); // last-resort magic-byte sniff
|
|
152
|
+
const dir = join(homedir(), ".hara", "signal", "media");
|
|
153
|
+
mkdirSync(dir, { recursive: true });
|
|
154
|
+
const path = join(dir, `sig_${Date.now()}_${ref.id.slice(-12)}${ext}`);
|
|
155
|
+
writeFileSync(path, bytes);
|
|
156
|
+
return path;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
name: "signal",
|
|
164
|
+
async send(chatId, text) {
|
|
165
|
+
for (const part of chunkText(text || "(empty)", 4000)) {
|
|
166
|
+
await rpc("send", { account: selfNumber, message: part, ...target(chatId) });
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
async sendFile(chatId, filePath) {
|
|
170
|
+
// Signal has no separate photo/document endpoints — everything is an `attachments` path on `send`.
|
|
171
|
+
// signal-cli reads the file off the local disk by path, so we just hand it the path (no upload step).
|
|
172
|
+
await rpc("send", { account: selfNumber, message: "", attachments: [filePath], ...target(chatId) }).catch(() => { });
|
|
173
|
+
// (readFileSync/basename imported for parity with the other adapters' file plumbing; signal-cli reads by path.)
|
|
174
|
+
void readFileSync;
|
|
175
|
+
void basename;
|
|
176
|
+
},
|
|
177
|
+
async start(onMessage, signal) {
|
|
178
|
+
console.error(`hara gateway[signal]: polling signal-cli daemon at ${base} as ${redactPhone(selfNumber)} (ensure \`signal-cli -a <number> daemon --http\` is running).`);
|
|
179
|
+
while (!signal.aborted) {
|
|
180
|
+
try {
|
|
181
|
+
// JSON-RPC `receive` drains all envelopes queued since the last call. timeout is the long-poll seconds
|
|
182
|
+
// the daemon will hold the request open waiting for new messages (server-side block → low-latency, low-spin).
|
|
183
|
+
const result = await rpc("receive", { account: selfNumber, timeout: 30 }, signal);
|
|
184
|
+
const envelopes = Array.isArray(result) ? result : result ? [result] : [];
|
|
185
|
+
for (const raw of envelopes) {
|
|
186
|
+
const parsed = parseSignalMessage(raw, selfNumber);
|
|
187
|
+
if (!parsed)
|
|
188
|
+
continue;
|
|
189
|
+
for (const ref of parsed.images) {
|
|
190
|
+
const path = await downloadAttachment(ref);
|
|
191
|
+
if (path)
|
|
192
|
+
(parsed.msg.images ??= []).push(path);
|
|
193
|
+
}
|
|
194
|
+
await onMessage(parsed.msg).catch(() => { });
|
|
195
|
+
}
|
|
196
|
+
if (envelopes.length === 0)
|
|
197
|
+
await sleep(500); // daemon returned immediately (no long-poll support) → gentle spin
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
if (signal.aborted)
|
|
201
|
+
break;
|
|
202
|
+
await sleep(2000); // daemon down / network blip → back off + retry (reconnect on drop)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
209
|
+
// signal-cli setup (the user does this once, OUTSIDE hara):
|
|
210
|
+
//
|
|
211
|
+
// 1. Install signal-cli brew install signal-cli (or download a release; needs a JRE)
|
|
212
|
+
// 2. Register OR link the number (a) register a NEW number:
|
|
213
|
+
// signal-cli -a +1555… register # solve the captcha it prompts for
|
|
214
|
+
// signal-cli -a +1555… verify 123456 # the SMS code
|
|
215
|
+
// (b) OR link to an EXISTING phone as a secondary device:
|
|
216
|
+
// signal-cli link -n "hara" # scan the QR from Signal app → Linked devices
|
|
217
|
+
// 3. Run the HTTP/JSON-RPC daemon signal-cli -a +1555… daemon --http localhost:8080
|
|
218
|
+
// 4. Point hara at it HARA_SIGNAL_RPC_URL=http://localhost:8080 HARA_SIGNAL_NUMBER=+1555…
|
|
219
|
+
// hara gateway --platform signal
|
|
220
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// tmux reply routing — lets a chat reply (WeChat) be injected back into an already-running tmux session
|
|
2
|
+
// (e.g. a Claude Code / codex / hara you started yourself), so "ping me on WeChat → I reply from outside →
|
|
3
|
+
// that session continues" works WITHOUT the daemon owning the process. The asking session registers its tmux
|
|
4
|
+
// pane (via the wechat-send `--ask` flow); the gateway daemon (sole WeChat receiver) injects the owner's reply
|
|
5
|
+
// into the oldest live registered pane with `tmux send-keys`. Borrows the ccgram keystroke-injection pattern.
|
|
6
|
+
//
|
|
7
|
+
// Safety: the daemon only reaches this AFTER its allow-list gate (so only the owner can trigger it), and it
|
|
8
|
+
// ONLY injects into panes that opted in by registering — never an arbitrary pane.
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
function dir() {
|
|
14
|
+
return join(homedir(), ".hara", "gateway");
|
|
15
|
+
}
|
|
16
|
+
function storePath() {
|
|
17
|
+
return join(dir(), "tmux-routes.json");
|
|
18
|
+
}
|
|
19
|
+
function load() {
|
|
20
|
+
try {
|
|
21
|
+
const j = JSON.parse(readFileSync(storePath(), "utf8"));
|
|
22
|
+
return Array.isArray(j?.routes) ? j.routes : [];
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function save(routes) {
|
|
29
|
+
mkdirSync(dir(), { recursive: true });
|
|
30
|
+
writeFileSync(storePath(), JSON.stringify({ routes }, null, 2));
|
|
31
|
+
}
|
|
32
|
+
/** Register (or refresh) a pane as awaiting a reply. De-dups by pane. mode "once" (default) = consumed after one
|
|
33
|
+
* reply; "bind" = persistent (every reply injects until unbound). */
|
|
34
|
+
export function registerTmuxRoute(pane, peer, cwd, mode = "once", now = Date.now()) {
|
|
35
|
+
const routes = load().filter((r) => r.pane !== pane);
|
|
36
|
+
routes.push({ pane, peer, cwd, ts: now, mode });
|
|
37
|
+
save(routes);
|
|
38
|
+
}
|
|
39
|
+
/** Remove a pane's route(s). Returns how many were removed. */
|
|
40
|
+
export function unbindPane(pane) {
|
|
41
|
+
const before = load();
|
|
42
|
+
const after = before.filter((r) => r.pane !== pane);
|
|
43
|
+
save(after);
|
|
44
|
+
return before.length - after.length;
|
|
45
|
+
}
|
|
46
|
+
/** All current routes (for `hara remote status`). */
|
|
47
|
+
export function listRoutes() {
|
|
48
|
+
return load();
|
|
49
|
+
}
|
|
50
|
+
/** Remove all persistent "bind" routes (the chat `/detach` command). Returns how many were removed. */
|
|
51
|
+
export function unbindBinds() {
|
|
52
|
+
const before = load();
|
|
53
|
+
const after = before.filter((r) => r.mode === "bind" ? false : true);
|
|
54
|
+
save(after);
|
|
55
|
+
return before.length - after.length;
|
|
56
|
+
}
|
|
57
|
+
/** Pure: pick the OLDEST live registered pane (FIFO — the longest-waiting ask answers first); return it plus the
|
|
58
|
+
* routes to keep. A "once" route is consumed after use; a "bind" route persists. Dead panes are always pruned. */
|
|
59
|
+
export function pickRoute(routes, isAlive) {
|
|
60
|
+
const live = routes.filter((r) => isAlive(r.pane)).sort((a, b) => a.ts - b.ts);
|
|
61
|
+
const chosen = live[0] ?? null;
|
|
62
|
+
const remaining = chosen && chosen.mode !== "bind" ? live.filter((r) => r.pane !== chosen.pane) : live;
|
|
63
|
+
return { chosen, remaining };
|
|
64
|
+
}
|
|
65
|
+
/** Is this tmux pane still alive? Checks membership in `list-panes -a` (display-message -t is too lenient and
|
|
66
|
+
* falls back to the active pane for a bogus target). false if tmux isn't running or the pane is gone. */
|
|
67
|
+
export function paneAlive(pane) {
|
|
68
|
+
try {
|
|
69
|
+
const out = execFileSync("tmux", ["list-panes", "-a", "-F", "#{pane_id}"], { encoding: "utf8", timeout: 3000 });
|
|
70
|
+
return out.split("\n").map((s) => s.trim()).includes(pane);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Type `text` into a tmux pane as if the user typed it, then press Enter (submits the line / sends the turn). */
|
|
77
|
+
export function injectTmux(pane, text) {
|
|
78
|
+
execFileSync("tmux", ["send-keys", "-t", pane, "-l", "--", text], { timeout: 3000 });
|
|
79
|
+
execFileSync("tmux", ["send-keys", "-t", pane, "Enter"], { timeout: 3000 });
|
|
80
|
+
}
|
|
81
|
+
/** Persistent ("bind") routes only — the panes whose OUTPUT we relay back to chat (two-way remote terminal). */
|
|
82
|
+
export function boundRoutes() {
|
|
83
|
+
return load().filter((r) => r.mode === "bind");
|
|
84
|
+
}
|
|
85
|
+
/** Capture a tmux pane's visible text (plain, no ANSI). null if unavailable. */
|
|
86
|
+
export function capturePane(pane) {
|
|
87
|
+
try {
|
|
88
|
+
return execFileSync("tmux", ["capture-pane", "-p", "-t", pane], { encoding: "utf8", timeout: 3000 });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Pure: the NEW output to relay, given what we last sent and the current pane capture. "" = nothing new.
|
|
95
|
+
* Handles the common append case, anchors on the last sent line when the pane has scrolled, and falls back to
|
|
96
|
+
* the tail when it can't re-anchor. */
|
|
97
|
+
export function outputDelta(lastSent, current) {
|
|
98
|
+
if (current === lastSent)
|
|
99
|
+
return "";
|
|
100
|
+
if (!lastSent)
|
|
101
|
+
return current; // caller decides whether to baseline (skip) or send on first sight
|
|
102
|
+
if (current.startsWith(lastSent))
|
|
103
|
+
return current.slice(lastSent.length);
|
|
104
|
+
const lines = lastSent.split("\n").filter((l) => l.trim());
|
|
105
|
+
const anchor = lines[lines.length - 1];
|
|
106
|
+
if (anchor) {
|
|
107
|
+
const idx = current.lastIndexOf(anchor);
|
|
108
|
+
if (idx >= 0)
|
|
109
|
+
return current.slice(idx + anchor.length);
|
|
110
|
+
}
|
|
111
|
+
return current.split("\n").slice(-20).join("\n"); // scrolled past our anchor → send the tail
|
|
112
|
+
}
|
|
113
|
+
/** Pick (and consume per mode) the oldest live registered pane WITHOUT injecting — so the caller can capture the
|
|
114
|
+
* pane before/after injecting and relay just the new output. Returns the pane id, or null if none pending. */
|
|
115
|
+
export function pickPaneForReply() {
|
|
116
|
+
const { chosen, remaining } = pickRoute(load(), paneAlive);
|
|
117
|
+
save(remaining);
|
|
118
|
+
return chosen?.pane ?? null;
|
|
119
|
+
}
|
|
120
|
+
/** Daemon entrypoint: deliver an inbound reply to the oldest live registered pane. Returns the pane id injected
|
|
121
|
+
* into, or null if there was no pending route (→ caller treats the message as a normal task). One-shot: the
|
|
122
|
+
* chosen route is consumed and dead panes are pruned. */
|
|
123
|
+
export function deliverToTmux(text) {
|
|
124
|
+
const { chosen, remaining } = pickRoute(load(), paneAlive);
|
|
125
|
+
save(remaining);
|
|
126
|
+
if (!chosen)
|
|
127
|
+
return null;
|
|
128
|
+
try {
|
|
129
|
+
injectTmux(chosen.pane, text);
|
|
130
|
+
return chosen.pane;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|