@sigrun/ext-gateway 0.0.0-stage → 0.1.2

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -2
  4. package/dist/access/codes.d.ts +18 -0
  5. package/dist/access/codes.js +34 -0
  6. package/dist/access/link-codes.d.ts +33 -0
  7. package/dist/access/link-codes.js +63 -0
  8. package/dist/access/loop-guard.d.ts +17 -0
  9. package/dist/access/loop-guard.js +42 -0
  10. package/dist/access/pairing.d.ts +74 -0
  11. package/dist/access/pairing.js +131 -0
  12. package/dist/commands/chat.d.ts +66 -0
  13. package/dist/commands/chat.js +371 -0
  14. package/dist/commands/link.d.ts +63 -0
  15. package/dist/commands/link.js +77 -0
  16. package/dist/commands/pair.d.ts +72 -0
  17. package/dist/commands/pair.js +110 -0
  18. package/dist/config.d.ts +65 -0
  19. package/dist/config.js +219 -0
  20. package/dist/engine.d.ts +108 -0
  21. package/dist/engine.js +645 -0
  22. package/dist/inbound/albums.d.ts +39 -0
  23. package/dist/inbound/albums.js +62 -0
  24. package/dist/inbound/envelope.d.ts +19 -0
  25. package/dist/inbound/envelope.js +39 -0
  26. package/dist/inbound/groups.d.ts +29 -0
  27. package/dist/inbound/groups.js +55 -0
  28. package/dist/inbound/media.d.ts +20 -0
  29. package/dist/inbound/media.js +88 -0
  30. package/dist/inbound/pipeline.d.ts +144 -0
  31. package/dist/inbound/pipeline.js +600 -0
  32. package/dist/inbound/tokens.d.ts +38 -0
  33. package/dist/inbound/tokens.js +42 -0
  34. package/dist/index.d.ts +86 -0
  35. package/dist/index.js +136 -0
  36. package/dist/interactions/answer.d.ts +42 -0
  37. package/dist/interactions/answer.js +112 -0
  38. package/dist/interactions/chat.d.ts +28 -0
  39. package/dist/interactions/chat.js +199 -0
  40. package/dist/interactions/prompts.d.ts +68 -0
  41. package/dist/interactions/prompts.js +246 -0
  42. package/dist/interactions/receipts.d.ts +7 -0
  43. package/dist/interactions/receipts.js +69 -0
  44. package/dist/interactions/render.d.ts +39 -0
  45. package/dist/interactions/render.js +122 -0
  46. package/dist/jobs.d.ts +34 -0
  47. package/dist/jobs.js +72 -0
  48. package/dist/log.d.ts +21 -0
  49. package/dist/log.js +42 -0
  50. package/dist/notify.d.ts +21 -0
  51. package/dist/notify.js +64 -0
  52. package/dist/outbound/delivery.d.ts +131 -0
  53. package/dist/outbound/delivery.js +404 -0
  54. package/dist/outbound/markdown.d.ts +36 -0
  55. package/dist/outbound/markdown.js +286 -0
  56. package/dist/outbound/render.d.ts +24 -0
  57. package/dist/outbound/render.js +65 -0
  58. package/dist/outbound/settled.d.ts +81 -0
  59. package/dist/outbound/settled.js +271 -0
  60. package/dist/outbound/stream.d.ts +38 -0
  61. package/dist/outbound/stream.js +205 -0
  62. package/dist/section.d.ts +16 -0
  63. package/dist/section.js +55 -0
  64. package/dist/session-meta.d.ts +18 -0
  65. package/dist/session-meta.js +39 -0
  66. package/dist/state/bindings.d.ts +55 -0
  67. package/dist/state/bindings.js +128 -0
  68. package/dist/state/identities.d.ts +65 -0
  69. package/dist/state/identities.js +135 -0
  70. package/dist/state/json-file.d.ts +64 -0
  71. package/dist/state/json-file.js +156 -0
  72. package/dist/state/ledger.d.ts +176 -0
  73. package/dist/state/ledger.js +256 -0
  74. package/dist/state/seen.d.ts +41 -0
  75. package/dist/state/seen.js +77 -0
  76. package/dist/status-route.d.ts +32 -0
  77. package/dist/status-route.js +26 -0
  78. package/dist/supervisor.d.ts +153 -0
  79. package/dist/supervisor.js +273 -0
  80. package/dist/tools.d.ts +27 -0
  81. package/dist/tools.js +207 -0
  82. package/package.json +54 -5
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The pairing store (spec §8.2): pending pairing requests persisted at
3
+ * `<stateDir>/gateway/pairing.json`.
4
+ *
5
+ * Pinned limits (the Hermes/OpenClaw shared parameters):
6
+ * - at most 1 pending request per sender (`limited: "sender"`);
7
+ * - at most 1 new request per sender per 600000 ms (`limited:
8
+ * "cooldown"` — enforced even after the previous request left the
9
+ * queue, so a spamming sender gets exactly one reply per window);
10
+ * - at most 3 pending per channel (`limited: "channel"` — a visible
11
+ * DoS floor an admin clears through `/pair deny` and the status
12
+ * route).
13
+ *
14
+ * Codes expire after 3600000 ms; expired entries are dropped on every
15
+ * read. Codes are never logged.
16
+ */
17
+ import { JsonFile } from "../state/json-file.js";
18
+ import { newCode } from "./codes.js";
19
+ /** The pinned expiry (ms). */
20
+ export const PAIRING_TTL_MS = 3_600_000;
21
+ /** The pinned per-sender request cooldown (ms). */
22
+ const REQUEST_COOLDOWN_MS = 600_000;
23
+ /** The pinned per-sender pending cap. */
24
+ const SENDER_PENDING_MAX = 1;
25
+ /** The pinned per-channel pending cap. */
26
+ const CHANNEL_PENDING_MAX = 3;
27
+ /** The cooldown key: one clock per (channel, sender). */
28
+ function cooldownKey(channel, senderId) {
29
+ return `${channel}:${senderId}`;
30
+ }
31
+ /**
32
+ * The pairing queue, one instance per engine. The public API is
33
+ * SYNCHRONOUS by interface (the pipeline's step 5 answers inline), so
34
+ * every mutation writes the file through JsonFile's SYNCHRONOUS
35
+ * update — a crash between the verdict and a deferred flush would
36
+ * silently lose a pending request (the operator's queue).
37
+ */
38
+ export class PairingStore {
39
+ file;
40
+ now;
41
+ mirror = { pending: [], lastRequest: {} };
42
+ constructor(path, opts = {}) {
43
+ this.file = new JsonFile(path, () => ({
44
+ pending: [],
45
+ lastRequest: {},
46
+ }));
47
+ this.now = opts.now ?? Date.now;
48
+ // The synchronous load: the constructor cannot await, and the queue
49
+ // is empty here so nothing can interleave the first observation.
50
+ this.mirror = this.file.readSync();
51
+ }
52
+ /**
53
+ * Create (or refuse) a pairing request. The caller hands `now` so the
54
+ * pipeline can pin a clock in tests; the store's own clock seeds the
55
+ * cooldown record.
56
+ */
57
+ request(channel, sender, chat, now) {
58
+ const live = this.livePending(now);
59
+ if (live.some((row) => row.channel === channel && row.senderId === sender.id)) {
60
+ return { limited: "sender" };
61
+ }
62
+ const last = this.mirror.lastRequest[cooldownKey(channel, sender.id)];
63
+ if (last !== undefined && now - last < REQUEST_COOLDOWN_MS) {
64
+ return { limited: "cooldown" };
65
+ }
66
+ if (live.filter((row) => row.channel === channel).length >= CHANNEL_PENDING_MAX) {
67
+ return { limited: "channel" };
68
+ }
69
+ const row = {
70
+ code: newCode(),
71
+ channel,
72
+ senderId: sender.id,
73
+ ...(sender.name === undefined ? {} : { senderName: sender.name }),
74
+ chat,
75
+ createdAt: now,
76
+ };
77
+ this.mirror = {
78
+ pending: [...live, row],
79
+ lastRequest: {
80
+ ...this.mirror.lastRequest,
81
+ [cooldownKey(channel, sender.id)]: now,
82
+ },
83
+ };
84
+ this.persist();
85
+ return { code: row.code };
86
+ }
87
+ /** The live pending rows, optionally filtered to one channel. */
88
+ pending(channel) {
89
+ return this.livePending(this.now()).filter((row) => channel === undefined || row.channel === channel);
90
+ }
91
+ /** Approve by code: the request leaves the queue and returns. */
92
+ approve(code, now = this.now()) {
93
+ const row = this.livePending(now).find((entry) => entry.code === code);
94
+ if (row === undefined)
95
+ return undefined;
96
+ this.mirror = {
97
+ ...this.mirror,
98
+ pending: this.livePending(now).filter((entry) => entry.code !== code),
99
+ };
100
+ this.persist();
101
+ return row;
102
+ }
103
+ /** Deny by code: true when a live request was removed. */
104
+ deny(code) {
105
+ const row = this.livePending(this.now()).find((entry) => entry.code === code);
106
+ if (row === undefined)
107
+ return false;
108
+ this.mirror = {
109
+ ...this.mirror,
110
+ pending: this.livePending(this.now()).filter((entry) => entry.code !== code),
111
+ };
112
+ this.persist();
113
+ return true;
114
+ }
115
+ /** The expired-row sweep: expired requests count against no limit. */
116
+ livePending(now) {
117
+ return this.mirror.pending.filter((row) => now - row.createdAt < PAIRING_TTL_MS);
118
+ }
119
+ /**
120
+ * Persist the mirror: the SYNCHRONOUS atomic write (see the class
121
+ * doc). A failed persist is loud, never swallowed — the verdict
122
+ * already returned to the pipeline, but the operator must see the
123
+ * file diverged (the JsonFile throw names the path).
124
+ */
125
+ persist() {
126
+ this.mirror = this.file.updateSync((draft) => {
127
+ draft.pending = this.mirror.pending;
128
+ draft.lastRequest = this.mirror.lastRequest;
129
+ });
130
+ }
131
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Chat commands (spec §8.5): the gateway's reserved slash commands plus
3
+ * non-colliding extension commands. They run inline at inbound stage 7,
4
+ * before the busy submission queue, and every operation acts on the
5
+ * chat's current binding.
6
+ *
7
+ * Unknown slash text is NOT consumed: prompt templates and skill
8
+ * expansion still belong to ordinary submission admission. `/link` and
9
+ * `/pair` are the authorization exceptions — linking needs no existing
10
+ * conversation access, and pairing applies its own effective-admin
11
+ * check. Every other command first proves write access to the bound
12
+ * conversation; `/attach` then separately proves write access to its
13
+ * target before changing the binding.
14
+ */
15
+ import type { ChannelContribution, InboundEvent, SigrunHost } from "@sigrun/sigrun/extension";
16
+ import type { Binding, BindingStore } from "../state/bindings.js";
17
+ import type { IdentityStore } from "../state/identities.js";
18
+ import type { PairingStore } from "../access/pairing.js";
19
+ import type { LinkCodes } from "../access/link-codes.js";
20
+ import type { GroupConfig } from "../config.js";
21
+ import type { EngineLog } from "../log.js";
22
+ /** One gateway chat command's native-menu metadata and placement rule. */
23
+ export interface GatewayCommand {
24
+ readonly name: string;
25
+ readonly description: string;
26
+ readonly where: "any" | "dm" | "not-anyone-group";
27
+ readonly admin?: true;
28
+ }
29
+ /** The reserved command table, in spec §8.5 order. */
30
+ export declare const GATEWAY_COMMANDS: readonly GatewayCommand[];
31
+ /** The shared stores and host surfaces the command handlers use. */
32
+ export interface ChatCommandDeps {
33
+ readonly host: SigrunHost;
34
+ readonly channel: ChannelContribution;
35
+ readonly bindings: BindingStore;
36
+ readonly identities: IdentityStore;
37
+ readonly pairing: PairingStore;
38
+ readonly linkCodes: LinkCodes;
39
+ readonly streams?: {
40
+ abandon(conversationId: number): void;
41
+ };
42
+ readonly channelOf: (channel: string) => ChannelContribution | undefined;
43
+ readonly log: EngineLog;
44
+ readonly now: () => number;
45
+ }
46
+ /** The admission context for one possible command. */
47
+ export interface ChatCommandContext {
48
+ readonly channel: string;
49
+ readonly event: InboundEvent;
50
+ readonly bindingKey: string;
51
+ readonly user: string | undefined;
52
+ readonly binding?: Binding;
53
+ readonly group?: GroupConfig;
54
+ /** Stage 4 owns redemption when enabled; stage 7 must refuse when disabled. */
55
+ readonly selfLink: boolean;
56
+ }
57
+ /** The native menu: reserved commands plus non-colliding extension rows. */
58
+ export declare function chatCommandMenu(host: SigrunHost): Promise<readonly {
59
+ readonly name: string;
60
+ readonly description: string;
61
+ }[]>;
62
+ /**
63
+ * Consume one gateway/extension command, or return `"not-a-command"`
64
+ * so admission continues with the original text.
65
+ */
66
+ export declare function handleChatCommand(deps: ChatCommandDeps, ctx: ChatCommandContext): Promise<"command" | "not-a-command">;
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Chat commands (spec §8.5): the gateway's reserved slash commands plus
3
+ * non-colliding extension commands. They run inline at inbound stage 7,
4
+ * before the busy submission queue, and every operation acts on the
5
+ * chat's current binding.
6
+ *
7
+ * Unknown slash text is NOT consumed: prompt templates and skill
8
+ * expansion still belong to ordinary submission admission. `/link` and
9
+ * `/pair` are the authorization exceptions — linking needs no existing
10
+ * conversation access, and pairing applies its own effective-admin
11
+ * check. Every other command first proves write access to the bound
12
+ * conversation; `/attach` then separately proves write access to its
13
+ * target before changing the binding.
14
+ */
15
+ import { redeemLink } from "./link.js";
16
+ import { runPair } from "./pair.js";
17
+ /** The reserved command table, in spec §8.5 order. */
18
+ export const GATEWAY_COMMANDS = [
19
+ { name: "new", description: "Start a fresh conversation and bind this chat to it", where: "any" },
20
+ { name: "stop", description: "Stop the live run", where: "any" },
21
+ { name: "model", description: "Show or change the conversation model", where: "any" },
22
+ { name: "thinking", description: "Show or change the thinking level", where: "any" },
23
+ { name: "compact", description: "Compact this conversation's history", where: "any" },
24
+ { name: "attach", description: "Bind this chat to an existing conversation", where: "not-anyone-group" },
25
+ { name: "status", description: "Show this chat's conversation status", where: "any" },
26
+ { name: "home", description: "Make this DM your home chat on this channel", where: "dm" },
27
+ { name: "link", description: "Link this chat account with a single-use code", where: "dm" },
28
+ { name: "pair", description: "Manage pending pairing requests (admin)", where: "dm", admin: true },
29
+ { name: "help", description: "List gateway and extension commands", where: "any" },
30
+ ];
31
+ const THINKING_LEVELS = [
32
+ "off",
33
+ "minimal",
34
+ "low",
35
+ "medium",
36
+ "high",
37
+ "xhigh",
38
+ "max",
39
+ ];
40
+ /** The native menu: reserved commands plus non-colliding extension rows. */
41
+ export async function chatCommandMenu(host) {
42
+ const extensions = await extensionCommands(host);
43
+ return [
44
+ ...GATEWAY_COMMANDS.map(({ name, description }) => ({ name, description })),
45
+ ...extensions.map(({ name, description }) => ({ name, description })),
46
+ ];
47
+ }
48
+ /**
49
+ * Consume one gateway/extension command, or return `"not-a-command"`
50
+ * so admission continues with the original text.
51
+ */
52
+ export async function handleChatCommand(deps, ctx) {
53
+ const parsed = parseCommand(ctx.event.text);
54
+ if (parsed === undefined)
55
+ return "not-a-command";
56
+ try {
57
+ return await dispatchChatCommand(deps, ctx, parsed);
58
+ }
59
+ catch (error) {
60
+ // User input must not turn an expected op rejection into an engine
61
+ // failure: the pipeline would release `seen` and the platform would
62
+ // redeliver the identical invalid command forever (the M12 posture).
63
+ const notice = expectedCommandError(error, parsed.name);
64
+ if (notice === undefined)
65
+ throw error;
66
+ await reply(deps, ctx, notice);
67
+ return "command";
68
+ }
69
+ }
70
+ /** Dispatch after parsing, under the public handler's expected-error boundary. */
71
+ async function dispatchChatCommand(deps, ctx, parsed) {
72
+ const gateway = GATEWAY_COMMANDS.find((command) => command.name === parsed.name);
73
+ if (gateway === undefined) {
74
+ const extension = (await extensionCommands(deps.host)).find((command) => command.name === parsed.name);
75
+ if (extension === undefined)
76
+ return "not-a-command";
77
+ if (!(await hasBoundWriteAccess(deps, ctx))) {
78
+ await reply(deps, ctx, "You do not have write access to this conversation.");
79
+ return "command";
80
+ }
81
+ if (ctx.user === undefined || ctx.binding === undefined) {
82
+ await reply(deps, ctx, "You do not have write access to this conversation.");
83
+ return "command";
84
+ }
85
+ const result = await deps.host.commands.run(ctx.binding.conversationId, extension.name, parsed.args, { user: ctx.user });
86
+ if (result.notice !== undefined && result.notice !== "") {
87
+ await reply(deps, ctx, result.notice);
88
+ }
89
+ return "command";
90
+ }
91
+ if (gateway.where === "dm" && ctx.event.chat.chatType !== "dm") {
92
+ await reply(deps, ctx, "send this in a direct message");
93
+ return "command";
94
+ }
95
+ if (gateway.name === "link" && !ctx.selfLink) {
96
+ await reply(deps, ctx, "Self-linking is disabled on this channel.");
97
+ return "command";
98
+ }
99
+ if (gateway.where === "not-anyone-group" && ctx.group?.members === "anyone") {
100
+ await reply(deps, ctx, "/attach is not available in this group.");
101
+ return "command";
102
+ }
103
+ if (gateway.name !== "link" && gateway.name !== "pair") {
104
+ if (!(await hasBoundWriteAccess(deps, ctx))) {
105
+ await reply(deps, ctx, "You do not have write access to this conversation.");
106
+ return "command";
107
+ }
108
+ }
109
+ switch (gateway.name) {
110
+ case "new":
111
+ await newConversation(deps, ctx);
112
+ break;
113
+ case "stop":
114
+ await stopRun(deps, ctx);
115
+ break;
116
+ case "model":
117
+ await configureModel(deps, ctx, parsed.args);
118
+ break;
119
+ case "thinking":
120
+ await configureThinking(deps, ctx, parsed.args);
121
+ break;
122
+ case "compact":
123
+ await requireBinding(deps, ctx, async (conversationId) => {
124
+ await deps.host.conversations.compact(conversationId);
125
+ await reply(deps, ctx, "Compaction queued.");
126
+ });
127
+ break;
128
+ case "attach":
129
+ await attachConversation(deps, ctx, parsed.args);
130
+ break;
131
+ case "status":
132
+ await showStatus(deps, ctx);
133
+ break;
134
+ case "home":
135
+ if (ctx.user !== undefined) {
136
+ await deps.identities.setHome(ctx.user, ctx.channel, ctx.event.chat);
137
+ await reply(deps, ctx, "This DM is now your home chat on this channel.");
138
+ }
139
+ break;
140
+ case "link": {
141
+ const notice = await redeemLink({
142
+ identities: deps.identities,
143
+ linkCodes: deps.linkCodes,
144
+ log: deps.log,
145
+ now: deps.now,
146
+ }, ctx.channel, { ...ctx.event, text: `/link ${parsed.args}` });
147
+ await reply(deps, ctx, notice);
148
+ break;
149
+ }
150
+ case "pair": {
151
+ const record = ctx.user === undefined
152
+ ? undefined
153
+ : await deps.host.users.get(ctx.user);
154
+ const notice = await runPair({
155
+ host: deps.host,
156
+ identities: deps.identities,
157
+ pairing: deps.pairing,
158
+ channelOf: deps.channelOf,
159
+ log: deps.log,
160
+ now: deps.now,
161
+ }, parsed.args, {
162
+ user: ctx.user,
163
+ admin: record?.admin === true || record?.groupAdmin === true,
164
+ });
165
+ await reply(deps, ctx, notice);
166
+ break;
167
+ }
168
+ case "help": {
169
+ const menu = await chatCommandMenu(deps.host);
170
+ await reply(deps, ctx, menu.map((command) => `/${command.name} — ${command.description}`).join("\n"));
171
+ break;
172
+ }
173
+ }
174
+ return "command";
175
+ }
176
+ /** Map only expected user-input/op failures; all other errors remain engine failures. */
177
+ function expectedCommandError(error, commandName) {
178
+ if (!(error instanceof Error))
179
+ return undefined;
180
+ if (error.name === "ConversationOpError") {
181
+ const body = error.body;
182
+ if (isRecord(body) && typeof body.error === "string") {
183
+ const field = typeof body.field === "string" ? ` ${body.field}` : "";
184
+ return `Could not run /${commandName}: ${body.error}${field}.`;
185
+ }
186
+ }
187
+ // The host bridge deliberately uses a plain Error for an unknown id.
188
+ if (commandName === "attach" && /^conversation .+ not found$/.test(error.message)) {
189
+ return error.message.endsWith(".") ? error.message : `${error.message}.`;
190
+ }
191
+ return undefined;
192
+ }
193
+ /** Parse `/name[@bot] args`; names outside the command grammar stay text. */
194
+ function parseCommand(text) {
195
+ const match = text.match(/^\/([a-z][a-z0-9-]*)(?:@[^\s]+)?(?:\s+([\s\S]*))?$/);
196
+ if (match === null)
197
+ return undefined;
198
+ return { name: match[1] ?? "", args: match[2]?.trim() ?? "" };
199
+ }
200
+ /** Only catalog rows carrying an extension slug are chat-invokable. */
201
+ async function extensionCommands(host) {
202
+ const reserved = new Set(GATEWAY_COMMANDS.map((command) => command.name));
203
+ return (await host.commands.list()).flatMap((command) => command.extension === undefined || reserved.has(command.name)
204
+ ? []
205
+ : [{ name: command.name, description: command.description, extension: command.extension }]);
206
+ }
207
+ /** The common bound-conversation write gate (link/pair are exempt). */
208
+ async function hasBoundWriteAccess(deps, ctx) {
209
+ if (ctx.user === undefined || ctx.binding === undefined)
210
+ return false;
211
+ const meta = await deps.host.conversations.meta(ctx.binding.conversationId);
212
+ const access = await deps.host.access.accessOf({ user: ctx.user }, { owner: meta.owner, sharing: meta.sharing });
213
+ return access === "write";
214
+ }
215
+ /** Run one operation that requires an existing bound conversation. */
216
+ async function requireBinding(deps, ctx, operation) {
217
+ if (ctx.binding === undefined) {
218
+ await reply(deps, ctx, "This chat is not bound to a conversation.");
219
+ return;
220
+ }
221
+ await operation(ctx.binding.conversationId);
222
+ }
223
+ /** `/new`: the same creation shape as first admission, then rebind. */
224
+ async function newConversation(deps, ctx) {
225
+ const owner = ctx.group?.owner ?? ctx.user;
226
+ if (owner === undefined) {
227
+ await reply(deps, ctx, "run this as a named user");
228
+ return;
229
+ }
230
+ const conversationId = await deps.host.conversations.create({
231
+ owner,
232
+ source: "gateway",
233
+ name: ctx.event.chat.title ?? `${deps.channel.label} DM`,
234
+ ...(ctx.group?.members !== "anyone"
235
+ ? {}
236
+ : {
237
+ agent: {
238
+ ...(ctx.group.tools === undefined ? {} : { tools: ctx.group.tools }),
239
+ ...(ctx.group.extensions === undefined ? {} : { extensions: ctx.group.extensions }),
240
+ },
241
+ }),
242
+ });
243
+ await deps.bindings.bind(ctx.bindingKey, conversationId, ctx.event.chat);
244
+ await reply(deps, ctx, `Started conversation ${String(conversationId)}.`);
245
+ }
246
+ /** `/stop`: Task 5 owns the live abort's exact "Stopped." notice. */
247
+ async function stopRun(deps, ctx) {
248
+ await requireBinding(deps, ctx, async (conversationId) => {
249
+ const latest = await latestView(deps.host, conversationId);
250
+ if (latest?.steer.busy !== true && (latest?.steer.queued ?? 0) === 0) {
251
+ await reply(deps, ctx, "Nothing is running.");
252
+ return;
253
+ }
254
+ await deps.host.conversations.abort(conversationId);
255
+ // An aborted preview never finalizes: release its watch/timers so
256
+ // Task 5's origin notice is the command's only reply.
257
+ deps.streams?.abandon(conversationId);
258
+ });
259
+ }
260
+ /** `/model`: no args lists the current and served refs; args configure. */
261
+ async function configureModel(deps, ctx, args) {
262
+ await requireBinding(deps, ctx, async (conversationId) => {
263
+ if (args !== "") {
264
+ await deps.host.conversations.configure(conversationId, { model: args });
265
+ await reply(deps, ctx, `Model set to ${args}.`);
266
+ return;
267
+ }
268
+ const latest = await latestView(deps.host, conversationId);
269
+ const current = modelRef(latest) ?? "not configured";
270
+ const models = await deps.host.models.list();
271
+ await reply(deps, ctx, [`Current model: ${current}`, ...models.map((model) => model.id)].join("\n"));
272
+ });
273
+ }
274
+ /** `/thinking`: no args lists levels; an arg configures the next run. */
275
+ async function configureThinking(deps, ctx, args) {
276
+ await requireBinding(deps, ctx, async (conversationId) => {
277
+ if (args !== "") {
278
+ if (!THINKING_LEVELS.includes(args)) {
279
+ await reply(deps, ctx, `usage: /thinking [${THINKING_LEVELS.join(" | ")}]`);
280
+ return;
281
+ }
282
+ await deps.host.conversations.configure(conversationId, {
283
+ thinking: args,
284
+ });
285
+ await reply(deps, ctx, `Thinking level set to ${args}.`);
286
+ return;
287
+ }
288
+ const latest = await latestView(deps.host, conversationId);
289
+ const current = thinkingLevel(latest) ?? "not configured";
290
+ await reply(deps, ctx, [`Current thinking level: ${current}`, ...THINKING_LEVELS].join("\n"));
291
+ });
292
+ }
293
+ /** `/attach`: authorize the target independently, then rebind. */
294
+ async function attachConversation(deps, ctx, args) {
295
+ if (!/^[1-9][0-9]*$/.test(args) || ctx.user === undefined) {
296
+ await reply(deps, ctx, "usage: /attach <conversation-id>");
297
+ return;
298
+ }
299
+ const numericId = Number(args);
300
+ // Numeric conversion above 2^53 loses identity, and >=1e21 formats in
301
+ // exponential notation. Reject before any host lookup or ConversationId cast.
302
+ if (!Number.isSafeInteger(numericId)) {
303
+ await reply(deps, ctx, "Unknown conversation.");
304
+ return;
305
+ }
306
+ const conversationId = numericId;
307
+ const meta = await deps.host.conversations.meta(conversationId);
308
+ const access = await deps.host.access.accessOf({ user: ctx.user }, { owner: meta.owner, sharing: meta.sharing });
309
+ if (access !== "write") {
310
+ await reply(deps, ctx, "You do not have write access to that conversation.");
311
+ return;
312
+ }
313
+ await deps.bindings.bind(ctx.bindingKey, conversationId, ctx.event.chat);
314
+ await reply(deps, ctx, `Attached to conversation ${args}.`);
315
+ }
316
+ /** `/status`: name, resolved model, live queue state, and binding id. */
317
+ async function showStatus(deps, ctx) {
318
+ await requireBinding(deps, ctx, async (conversationId) => {
319
+ const [meta, latest] = await Promise.all([
320
+ deps.host.conversations.meta(conversationId),
321
+ latestView(deps.host, conversationId),
322
+ ]);
323
+ const state = latest?.steer.busy === true
324
+ ? `busy, ${latest.steer.queued} queued`
325
+ : `idle, ${latest?.steer.queued ?? 0} queued`;
326
+ await reply(deps, ctx, [
327
+ `Conversation: ${meta.name ?? "unnamed"}`,
328
+ `Model: ${modelRef(latest) ?? "not configured"}`,
329
+ `State: ${state}`,
330
+ `Bound conversation: ${String(conversationId)}`,
331
+ ].join("\n"));
332
+ });
333
+ }
334
+ /** Read the newest frame and release the one-shot watch immediately. */
335
+ async function latestView(host, conversationId) {
336
+ let latest;
337
+ const release = await host.conversations.watch(conversationId, (view) => {
338
+ latest = view;
339
+ });
340
+ await release();
341
+ return latest;
342
+ }
343
+ /** Structural read of the resolved agent document (wire docs are JSON). */
344
+ function agentDoc(view) {
345
+ const doc = view?.docs["pi.agent"];
346
+ return isRecord(doc) ? doc : undefined;
347
+ }
348
+ function modelRef(view) {
349
+ const model = agentDoc(view)?.model;
350
+ if (!isRecord(model))
351
+ return undefined;
352
+ return typeof model.provider === "string" && typeof model.modelId === "string"
353
+ ? `${model.provider}/${model.modelId}`
354
+ : undefined;
355
+ }
356
+ function thinkingLevel(view) {
357
+ const level = agentDoc(view)?.thinkingLevel;
358
+ return typeof level === "string" ? level : undefined;
359
+ }
360
+ function isRecord(value) {
361
+ return typeof value === "object" && value !== null && !Array.isArray(value);
362
+ }
363
+ /** Command replies are best-effort transport notices, like pipeline notices. */
364
+ async function reply(deps, ctx, text) {
365
+ try {
366
+ await deps.channel.send({ chat: ctx.event.chat, text });
367
+ }
368
+ catch (error) {
369
+ deps.log.error("chat command reply send failed", error);
370
+ }
371
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Self-linking (spec §8.2): the webui/TUI `/link <channel>` command
3
+ * issues a single-use code bound to the invoking user and channel, and
4
+ * the chat-side `redeemLink` (pipeline step 4) consumes it in a DM.
5
+ *
6
+ * Proof by holding both sessions: the code is the only bridge, so it is
7
+ * single-use, channel-bound, expires with pairing codes (3600000 ms),
8
+ * and a code exposed in a GROUP is invalidated (other members saw it —
9
+ * M9; the group refusal lives in the pipeline step, which owns the
10
+ * chat-shape check).
11
+ *
12
+ * A sender already linked to another user gets core's conflict refusal
13
+ * and the pinned reply; the code is NOT consumed until the link lands,
14
+ * so the user can remove the conflicting link and send the SAME code.
15
+ */
16
+ import type { InboundEvent } from "@sigrun/sigrun/extension";
17
+ import type { EngineLog } from "../log.js";
18
+ import type { IdentityStore } from "../state/identities.js";
19
+ import type { LinkCodes } from "../access/link-codes.js";
20
+ import type { GatewayConfig } from "../config.js";
21
+ /** The pinned refusal for an unnamed caller (spec §8.2). */
22
+ export declare const UNNAMED_CALLER = "run this as a named user";
23
+ /** The pinned conflict reply (the code stays live). */
24
+ export declare const LINK_CONFLICT_REPLY = "This chat account is already linked to another sigrun user. Remove that link under account links in sigrun, then send the code again.";
25
+ /** The chat-side redeem dependencies (the engine wires them). */
26
+ export interface LinkDeps {
27
+ readonly identities: IdentityStore;
28
+ readonly linkCodes: LinkCodes;
29
+ readonly log: EngineLog;
30
+ readonly now: () => number;
31
+ }
32
+ /**
33
+ * Handle `/link <code>` from a DM: redeem, link, and return the reply
34
+ * text (the pipeline sends it). Core owns the conflict check; the
35
+ * static allowlist never blocks a self-link (it shadows the link
36
+ * afterward, exactly as any stale link).
37
+ */
38
+ export declare function redeemLink(deps: LinkDeps, channel: string, event: InboundEvent): Promise<string>;
39
+ /** The `/link <channel>` webui/TUI command's dependencies. */
40
+ export interface LinkCommandDeps {
41
+ /** The live config read (channel enablement is per-read). */
42
+ readonly readConfig: () => Promise<GatewayConfig>;
43
+ readonly linkCodes: LinkCodes;
44
+ /** The channel ids the engine recorded at start (unknown-channel refusal). */
45
+ readonly knownChannels: () => readonly string[];
46
+ readonly now: () => number;
47
+ }
48
+ /** The command contribution shape (the loader validates name/run). */
49
+ export interface LinkCommand {
50
+ readonly name: string;
51
+ readonly description: string;
52
+ readonly args: string;
53
+ run(ctx: {
54
+ readonly principal: {
55
+ readonly user: string | undefined;
56
+ };
57
+ readonly args: string;
58
+ }): Promise<{
59
+ readonly notice: string;
60
+ }>;
61
+ }
62
+ /** Build the `link` slash command over the engine's shared state. */
63
+ export declare function createLinkCommand(deps: LinkCommandDeps): LinkCommand;