@addozhang/dsh-discord 0.1.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.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -0
  3. package/cordis.patch.yml +14 -0
  4. package/lib/client/DiscordSettingsCard.d.ts +6 -0
  5. package/lib/client/PluginCard.d.ts +43 -0
  6. package/lib/client/card-controller.d.ts +22 -0
  7. package/lib/client/card-form.d.ts +111 -0
  8. package/lib/client/fields.d.ts +50 -0
  9. package/lib/client/index.d.ts +12 -0
  10. package/lib/client/nav-icon.d.ts +21 -0
  11. package/lib/client/settings-model.d.ts +49 -0
  12. package/lib/client/slot-contract.d.ts +57 -0
  13. package/lib/client/snapshot-store.d.ts +9 -0
  14. package/lib/client.js +1622 -0
  15. package/lib/compose.d.ts +158 -0
  16. package/lib/compose.js +227 -0
  17. package/lib/credential.d.ts +20 -0
  18. package/lib/credential.js +24 -0
  19. package/lib/discord/commands.d.ts +45 -0
  20. package/lib/discord/commands.js +87 -0
  21. package/lib/discord/components.d.ts +29 -0
  22. package/lib/discord/components.js +57 -0
  23. package/lib/discord/delivery.d.ts +21 -0
  24. package/lib/discord/delivery.js +31 -0
  25. package/lib/discord/interaction-lifecycle.d.ts +42 -0
  26. package/lib/discord/interaction-lifecycle.js +78 -0
  27. package/lib/discord/rest.d.ts +74 -0
  28. package/lib/discord/rest.js +149 -0
  29. package/lib/discord/selector.d.ts +39 -0
  30. package/lib/discord/selector.js +70 -0
  31. package/lib/discord/thread-port.d.ts +37 -0
  32. package/lib/discord/thread-port.js +58 -0
  33. package/lib/dsh/api-proxy-face.d.ts +253 -0
  34. package/lib/dsh/api-proxy-face.js +370 -0
  35. package/lib/features/adapter-status.d.ts +111 -0
  36. package/lib/features/adapter-status.js +103 -0
  37. package/lib/features/approval-expiry.d.ts +27 -0
  38. package/lib/features/approval-expiry.js +36 -0
  39. package/lib/features/approval-routing.d.ts +50 -0
  40. package/lib/features/approval-routing.js +57 -0
  41. package/lib/features/approval-store.d.ts +86 -0
  42. package/lib/features/approval-store.js +119 -0
  43. package/lib/features/approval-view.d.ts +47 -0
  44. package/lib/features/approval-view.js +48 -0
  45. package/lib/features/ask-wiring.d.ts +74 -0
  46. package/lib/features/ask-wiring.js +151 -0
  47. package/lib/features/host-status.d.ts +41 -0
  48. package/lib/features/host-status.js +24 -0
  49. package/lib/features/image-collection.d.ts +56 -0
  50. package/lib/features/image-collection.js +79 -0
  51. package/lib/features/image-download.d.ts +48 -0
  52. package/lib/features/image-download.js +77 -0
  53. package/lib/features/image-submission.d.ts +67 -0
  54. package/lib/features/image-submission.js +57 -0
  55. package/lib/features/interaction-router.d.ts +84 -0
  56. package/lib/features/interaction-router.js +655 -0
  57. package/lib/features/model-control.d.ts +79 -0
  58. package/lib/features/model-control.js +51 -0
  59. package/lib/features/preset-control.d.ts +84 -0
  60. package/lib/features/preset-control.js +43 -0
  61. package/lib/features/project-bind.d.ts +69 -0
  62. package/lib/features/project-bind.js +57 -0
  63. package/lib/features/project-info.d.ts +25 -0
  64. package/lib/features/project-info.js +22 -0
  65. package/lib/features/project-list.d.ts +79 -0
  66. package/lib/features/project-list.js +93 -0
  67. package/lib/features/prompt-submission.d.ts +53 -0
  68. package/lib/features/prompt-submission.js +47 -0
  69. package/lib/features/question-expiry.d.ts +53 -0
  70. package/lib/features/question-expiry.js +56 -0
  71. package/lib/features/question-routing.d.ts +108 -0
  72. package/lib/features/question-routing.js +162 -0
  73. package/lib/features/question-store.d.ts +124 -0
  74. package/lib/features/question-store.js +186 -0
  75. package/lib/features/question-view.d.ts +58 -0
  76. package/lib/features/question-view.js +81 -0
  77. package/lib/features/queue-control.d.ts +63 -0
  78. package/lib/features/queue-control.js +31 -0
  79. package/lib/features/reconcile-bindings.d.ts +75 -0
  80. package/lib/features/reconcile-bindings.js +54 -0
  81. package/lib/features/reconcile-deliveries.d.ts +29 -0
  82. package/lib/features/reconcile-deliveries.js +28 -0
  83. package/lib/features/reconcile-events.d.ts +49 -0
  84. package/lib/features/reconcile-events.js +50 -0
  85. package/lib/features/reconcile-intents.d.ts +32 -0
  86. package/lib/features/reconcile-intents.js +32 -0
  87. package/lib/features/reconcile-interactions.d.ts +59 -0
  88. package/lib/features/reconcile-interactions.js +54 -0
  89. package/lib/features/session-adopt.d.ts +89 -0
  90. package/lib/features/session-adopt.js +77 -0
  91. package/lib/features/session-creation.d.ts +49 -0
  92. package/lib/features/session-creation.js +43 -0
  93. package/lib/features/session-mainline.d.ts +75 -0
  94. package/lib/features/session-mainline.js +96 -0
  95. package/lib/features/session-resume.d.ts +44 -0
  96. package/lib/features/session-resume.js +40 -0
  97. package/lib/features/skill-run.d.ts +48 -0
  98. package/lib/features/skill-run.js +25 -0
  99. package/lib/features/steer-control.d.ts +37 -0
  100. package/lib/features/steer-control.js +28 -0
  101. package/lib/features/stop-control.d.ts +38 -0
  102. package/lib/features/stop-control.js +31 -0
  103. package/lib/features/task-admission.d.ts +29 -0
  104. package/lib/features/task-admission.js +21 -0
  105. package/lib/features/thread-continuation.d.ts +36 -0
  106. package/lib/features/thread-continuation.js +18 -0
  107. package/lib/features/thread-creation.d.ts +81 -0
  108. package/lib/features/thread-creation.js +81 -0
  109. package/lib/features/thread-routing.d.ts +49 -0
  110. package/lib/features/thread-routing.js +34 -0
  111. package/lib/features/turn-ownership.d.ts +39 -0
  112. package/lib/features/turn-ownership.js +40 -0
  113. package/lib/features/unbound-mention.d.ts +49 -0
  114. package/lib/features/unbound-mention.js +31 -0
  115. package/lib/features/workspace-channel.d.ts +41 -0
  116. package/lib/features/workspace-channel.js +47 -0
  117. package/lib/gateway/gateway.d.ts +50 -0
  118. package/lib/gateway/gateway.js +229 -0
  119. package/lib/gateway/inbound.d.ts +85 -0
  120. package/lib/gateway/inbound.js +186 -0
  121. package/lib/gateway/ingress.d.ts +15 -0
  122. package/lib/gateway/ingress.js +22 -0
  123. package/lib/i18n.d.ts +92 -0
  124. package/lib/i18n.js +180 -0
  125. package/lib/index.d.ts +17 -0
  126. package/lib/index.js +869 -0
  127. package/lib/lifecycle.d.ts +44 -0
  128. package/lib/lifecycle.js +84 -0
  129. package/lib/policy/authorization.d.ts +41 -0
  130. package/lib/policy/authorization.js +64 -0
  131. package/lib/policy/disclosure.d.ts +93 -0
  132. package/lib/policy/disclosure.js +153 -0
  133. package/lib/policy/guard.d.ts +31 -0
  134. package/lib/policy/guard.js +46 -0
  135. package/lib/policy/suppress.d.ts +13 -0
  136. package/lib/policy/suppress.js +20 -0
  137. package/lib/settings-namespace.d.ts +7 -0
  138. package/lib/settings-namespace.js +6 -0
  139. package/lib/settings.d.ts +37 -0
  140. package/lib/settings.js +102 -0
  141. package/lib/startup.d.ts +16 -0
  142. package/lib/startup.js +60 -0
  143. package/lib/state/bindings.d.ts +46 -0
  144. package/lib/state/bindings.js +55 -0
  145. package/lib/state/channel-bindings.d.ts +37 -0
  146. package/lib/state/channel-bindings.js +43 -0
  147. package/lib/state/domain.d.ts +82 -0
  148. package/lib/state/domain.js +86 -0
  149. package/lib/state/effect-machine.d.ts +37 -0
  150. package/lib/state/effect-machine.js +52 -0
  151. package/lib/state/fail-closed.d.ts +44 -0
  152. package/lib/state/fail-closed.js +57 -0
  153. package/lib/state/intents.d.ts +51 -0
  154. package/lib/state/intents.js +73 -0
  155. package/lib/state/records.d.ts +37 -0
  156. package/lib/state/records.js +30 -0
  157. package/lib/state/retention.d.ts +74 -0
  158. package/lib/state/retention.js +68 -0
  159. package/lib/state/session-owners.d.ts +50 -0
  160. package/lib/state/session-owners.js +50 -0
  161. package/lib/stream/finalizer.d.ts +49 -0
  162. package/lib/stream/finalizer.js +67 -0
  163. package/lib/stream/icons.d.ts +15 -0
  164. package/lib/stream/icons.js +36 -0
  165. package/lib/stream/live.d.ts +140 -0
  166. package/lib/stream/live.js +515 -0
  167. package/lib/stream/markdown.d.ts +24 -0
  168. package/lib/stream/markdown.js +95 -0
  169. package/lib/stream/outbound.d.ts +27 -0
  170. package/lib/stream/outbound.js +25 -0
  171. package/lib/stream/render-fence.d.ts +27 -0
  172. package/lib/stream/render-fence.js +37 -0
  173. package/lib/stream/render-model.d.ts +48 -0
  174. package/lib/stream/render-model.js +68 -0
  175. package/lib/stream/splitter.d.ts +8 -0
  176. package/lib/stream/splitter.js +58 -0
  177. package/lib/stream/tool-view.d.ts +40 -0
  178. package/lib/stream/tool-view.js +39 -0
  179. package/lib/stream/typing.d.ts +22 -0
  180. package/lib/stream/typing.js +58 -0
  181. package/lib/stream/update-scheduler.d.ts +20 -0
  182. package/lib/stream/update-scheduler.js +65 -0
  183. package/package.json +86 -0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Approval control rendering (design.md §8, task 13.1). A pending DSH
3
+ * approval renders as Allow once / Reject buttons whose custom_ids are
4
+ * opaque registry keys minted per control — session, rpc, approval, tool,
5
+ * and reason data never ride the Discord wire. Visible text reuses the
6
+ * activity surface's safe allowlisted labels (generic fallback) and the
7
+ * outbound mention posture: the host-supplied reason is mention-neutralized
8
+ * and bounded before display.
9
+ */
10
+ import { DISCORD_SUPPRESS_NOTIFICATIONS_FLAG } from '../policy/disclosure.js';
11
+ import { suppressMentionSyntax } from '../policy/suppress.js';
12
+ import { toolLabel } from '../stream/tool-view.js';
13
+ /** The reason text cap; the host decides content, the adapter decides size. */
14
+ const REASON_MAX = 300;
15
+ /** Discord interactive-component ids (action row = 1, button = 2). */
16
+ const ACTION_ROW = 1;
17
+ const BUTTON = 2;
18
+ const BUTTON_STYLE_SUCCESS = 3;
19
+ const BUTTON_STYLE_DANGER = 4;
20
+ function boundedReason(reason) {
21
+ const neutralized = suppressMentionSyntax(reason);
22
+ return neutralized.length <= REASON_MAX ? neutralized : `${neutralized.slice(0, REASON_MAX)}…`;
23
+ }
24
+ /**
25
+ * Render one pending approval. Both controls resolve through the registry to
26
+ * `{ approvalId, action, expiresAtMs }`, so routing (13.2/13.3) recovers the
27
+ * pending record from durable state, never from the wire.
28
+ */
29
+ export function renderApprovalControls(input) {
30
+ const { registry } = input;
31
+ const allowId = registry.register({ approvalId: input.approvalId, action: 'allow', expiresAtMs: input.expiresAtMs });
32
+ const rejectId = registry.register({ approvalId: input.approvalId, action: 'reject', expiresAtMs: input.expiresAtMs });
33
+ const reason = input.reason === undefined ? '' : `\n${boundedReason(input.reason)}`;
34
+ const content = `Approval required — ${toolLabel(input.toolName)}${reason}`;
35
+ return {
36
+ content,
37
+ flags: DISCORD_SUPPRESS_NOTIFICATIONS_FLAG,
38
+ components: [
39
+ {
40
+ type: ACTION_ROW,
41
+ components: [
42
+ { type: BUTTON, style: BUTTON_STYLE_SUCCESS, label: 'Allow once', custom_id: allowId },
43
+ { type: BUTTON, style: BUTTON_STYLE_DANGER, label: 'Reject', custom_id: rejectId },
44
+ ],
45
+ },
46
+ ],
47
+ };
48
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Composition wiring for the answerable server-request face (approvals and
3
+ * questions): ownership resolution, record opening, control rendering, and
4
+ * render-failure abandonment in one testable unit. This is the layer where
5
+ * the live incidents lived (an actor lost across turns, a batch built from
6
+ * the wrong field names) — so the wiring is a module, not inline glue.
7
+ *
8
+ * Ownership rule: the active turn's Discord author wins; when DSH starts
9
+ * follow-up turns without a new submission the durable thread creator falls
10
+ * back as owner (Milestone 1: one thread, one session, one owner).
11
+ */
12
+ import type { ComponentRegistry } from '../discord/components.js';
13
+ import type { ApprovalStore } from './approval-store.js';
14
+ import type { QuestionStore } from './question-store.js';
15
+ import type { DshTurnCancelPort } from './question-expiry.js';
16
+ /** One approval ask projected from the Host mux frame. */
17
+ export interface ApprovalAskInput {
18
+ sessionId: string;
19
+ threadId: string;
20
+ rpcId: string;
21
+ approvalId: string;
22
+ toolName: string;
23
+ reason?: string | undefined;
24
+ expiresAtMs: number;
25
+ }
26
+ /** One question ask projected from the Host mux frame (raw question rows). */
27
+ export interface QuestionAskInput {
28
+ sessionId: string;
29
+ threadId: string;
30
+ rpcId: string;
31
+ expiresAtMs: number;
32
+ questions: ReadonlyArray<Record<string, unknown>>;
33
+ }
34
+ export interface AskWiringDeps {
35
+ registry: ComponentRegistry;
36
+ approvals: ApprovalStore;
37
+ questions: QuestionStore;
38
+ /** Cancellation face for asks that can never be answered. */
39
+ cancelPort: DshTurnCancelPort;
40
+ nowMs(): number;
41
+ log(event: string, detail?: unknown): void;
42
+ /** Request id of the adapter-owned active turn, when the adapter owns one. */
43
+ activeTurnRequestId(sessionId: string): string | undefined;
44
+ /** The Discord author recorded for a submitted request id. */
45
+ turnActor(requestId: string): string | undefined;
46
+ /** The durable thread binding's creator, when the thread is bound. */
47
+ threadOwner(threadId: string): string | undefined;
48
+ /**
49
+ * Post one rendered control message into the ask's thread. `stored` is
50
+ * true only when Discord accepted the message and returned its id;
51
+ * otherwise `reason` carries the failure cause for the log.
52
+ */
53
+ postMessage(threadId: string, payload: unknown): Promise<{
54
+ stored: boolean;
55
+ reason?: string;
56
+ messageId?: string;
57
+ }>;
58
+ }
59
+ export declare function createAskWiring(deps: AskWiringDeps): {
60
+ /** Retire a rendered control (clear components) once the ask is settled. */
61
+ disableControl(key: string): Promise<void>;
62
+ onApprovalRequested(input: ApprovalAskInput): void;
63
+ onApprovalResolved(input: {
64
+ sessionId: string;
65
+ approvalId: string;
66
+ outcome?: string | undefined;
67
+ }): void;
68
+ onQuestionRequested(input: QuestionAskInput): void;
69
+ onQuestionResolved(input: {
70
+ sessionId: string;
71
+ questionRpcId: string;
72
+ outcome: 'answered' | 'cancelled';
73
+ }): void;
74
+ };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Composition wiring for the answerable server-request face (approvals and
3
+ * questions): ownership resolution, record opening, control rendering, and
4
+ * render-failure abandonment in one testable unit. This is the layer where
5
+ * the live incidents lived (an actor lost across turns, a batch built from
6
+ * the wrong field names) — so the wiring is a module, not inline glue.
7
+ *
8
+ * Ownership rule: the active turn's Discord author wins; when DSH starts
9
+ * follow-up turns without a new submission the durable thread creator falls
10
+ * back as owner (Milestone 1: one thread, one session, one owner).
11
+ */
12
+ import { renderApprovalControls } from './approval-view.js';
13
+ import { renderQuestionControls } from './question-view.js';
14
+ import { abandonUnrenderableQuestion } from './question-expiry.js';
15
+ import { handleRemoteResolution } from './question-routing.js';
16
+ export function createAskWiring(deps) {
17
+ /** Discord message refs for rendered controls, keyed by ask id. */
18
+ const controlMessages = new Map();
19
+ const disableControl = async (key) => {
20
+ const target = controlMessages.get(key);
21
+ if (target === undefined)
22
+ return;
23
+ controlMessages.delete(key);
24
+ // Retirement is best-effort: a failed disable must never fail the ask's
25
+ // settled outcome — the registry TTL keeps stray clicks answerable.
26
+ try {
27
+ const posted = await deps.postMessage(target.channelId, { components: [] });
28
+ if (!posted.stored)
29
+ deps.log('discord_control_disable_failed', { key });
30
+ }
31
+ catch (cause) {
32
+ deps.log('discord_control_disable_failed', { key, cause: String(cause) });
33
+ }
34
+ };
35
+ function resolveAskActor(sessionId, threadId) {
36
+ const requestId = deps.activeTurnRequestId(sessionId);
37
+ if (requestId !== undefined) {
38
+ const actor = deps.turnActor(requestId);
39
+ if (actor !== undefined)
40
+ return { actorUserId: actor, actorSource: 'turn' };
41
+ }
42
+ const owner = deps.threadOwner(threadId);
43
+ if (owner !== undefined)
44
+ return { actorUserId: owner, actorSource: 'thread-binding' };
45
+ return { actorUserId: '', actorSource: 'none' };
46
+ }
47
+ return {
48
+ disableControl,
49
+ onApprovalRequested: (input) => {
50
+ const { actorUserId, actorSource } = resolveAskActor(input.sessionId, input.threadId);
51
+ const requestId = deps.activeTurnRequestId(input.sessionId) ?? '';
52
+ deps.log('discord_approval_opened', {
53
+ approvalId: input.approvalId, requestId, actorUserId, actorSource,
54
+ sessionId: input.sessionId, threadId: input.threadId,
55
+ });
56
+ deps.approvals.open({
57
+ approvalId: input.approvalId,
58
+ sessionId: input.sessionId,
59
+ threadId: input.threadId,
60
+ requestId,
61
+ rpcId: input.rpcId,
62
+ actorUserId,
63
+ toolName: input.toolName,
64
+ reason: input.reason,
65
+ expiresAtMs: input.expiresAtMs,
66
+ state: 'pending',
67
+ });
68
+ const payload = renderApprovalControls({
69
+ registry: deps.registry,
70
+ sessionId: input.sessionId,
71
+ rpcId: input.rpcId,
72
+ approvalId: input.approvalId,
73
+ toolName: input.toolName,
74
+ reason: input.reason,
75
+ expiresAtMs: input.expiresAtMs,
76
+ });
77
+ void deps.postMessage(input.threadId, payload)
78
+ .then(sent => {
79
+ if (sent.stored && sent.messageId !== undefined) {
80
+ controlMessages.set(input.approvalId, { channelId: input.threadId, messageId: sent.messageId });
81
+ }
82
+ })
83
+ .catch((cause) => { deps.log('discord_approval_render_failed', String(cause)); });
84
+ },
85
+ onApprovalResolved: (input) => {
86
+ const record = deps.approvals.get(input.approvalId);
87
+ if (record !== undefined && record.state === 'pending') {
88
+ void deps.approvals.markResolved(input.approvalId, input.outcome === 'allowed-once' ? 'allowed-once' : 'rejected', deps.nowMs());
89
+ }
90
+ void disableControl(input.approvalId);
91
+ },
92
+ onQuestionRequested: (input) => {
93
+ const { actorUserId, actorSource } = resolveAskActor(input.sessionId, input.threadId);
94
+ const requestId = deps.activeTurnRequestId(input.sessionId) ?? '';
95
+ deps.log('discord_question_opened', {
96
+ questionRpcId: input.rpcId, requestId, actorUserId, actorSource,
97
+ sessionId: input.sessionId, threadId: input.threadId,
98
+ });
99
+ const batch = {
100
+ questionRpcId: input.rpcId,
101
+ sessionId: input.sessionId,
102
+ threadId: input.threadId,
103
+ requestId,
104
+ actorUserId,
105
+ expiresAtMs: input.expiresAtMs,
106
+ questions: input.questions.map(question => ({
107
+ id: typeof question['id'] === 'string' ? question['id'] : '',
108
+ question: typeof question['question'] === 'string' ? question['question'] : '',
109
+ header: typeof question['header'] === 'string' ? question['header'] : undefined,
110
+ options: Array.isArray(question['options'])
111
+ ? question['options'].map(option => ({
112
+ label: typeof option.label === 'string'
113
+ ? option.label
114
+ : '',
115
+ }))
116
+ : undefined,
117
+ multiSelect: question['multiSelect'] === true,
118
+ })),
119
+ };
120
+ const opened = deps.questions.open(batch);
121
+ if (!opened.ok) {
122
+ deps.log('discord_question_open_rejected', { error: opened.error });
123
+ return;
124
+ }
125
+ const payload = renderQuestionControls({ registry: deps.registry, batch });
126
+ // Controls that never reached Discord can never be answered: cancel
127
+ // the owning Turn now instead of letting the sweep wait out the
128
+ // deadline with DSH's tool call hanging.
129
+ const abandonQuestion = (cause) => {
130
+ deps.log('discord_question_render_failed', cause);
131
+ void abandonUnrenderableQuestion({ store: deps.questions, cancelPort: deps.cancelPort, nowMs: () => deps.nowMs() }, input.rpcId).catch(() => { });
132
+ };
133
+ void deps.postMessage(input.threadId, payload)
134
+ .then(sent => {
135
+ if (!sent.stored || sent.messageId === undefined) {
136
+ abandonQuestion(sent.reason ?? 'post rejected');
137
+ return;
138
+ }
139
+ controlMessages.set(input.rpcId, { channelId: input.threadId, messageId: sent.messageId });
140
+ })
141
+ .catch((cause) => { abandonQuestion(String(cause)); });
142
+ },
143
+ onQuestionResolved: (input) => {
144
+ void handleRemoteResolution({
145
+ store: deps.questions,
146
+ controls: { disable: key => disableControl(key) },
147
+ nowMs: () => deps.nowMs(),
148
+ }, { questionRpcId: input.questionRpcId, outcome: input.outcome }).catch((cause) => { deps.log('discord_question_remote_resolve_failed', String(cause)); });
149
+ },
150
+ };
151
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `/host status` and the process-management refusal (design.md Non-Goals,
3
+ * task 10.5). The adapter reports connectivity and version metadata and does
4
+ * nothing else to its host: start/stop/restart/upgrade requests refuse
5
+ * explicitly and ephemerally — the embedded adapter cannot manage the
6
+ * process it lives in.
7
+ */
8
+ export interface DshHostPort {
9
+ status(): Promise<{
10
+ outcome: 'completed';
11
+ connected: boolean;
12
+ version: string | undefined;
13
+ } | {
14
+ outcome: 'failed';
15
+ } | {
16
+ outcome: 'unknown';
17
+ }>;
18
+ }
19
+ export type HostStatusView = {
20
+ outcome: 'ok';
21
+ connected: boolean;
22
+ version: string | undefined;
23
+ } | {
24
+ outcome: 'failed';
25
+ reason: 'host-status-unavailable' | 'host-status-unknown';
26
+ };
27
+ export declare function hostStatus(port: DshHostPort): Promise<HostStatusView>;
28
+ export type ProcessActionResult = {
29
+ outcome: 'refused';
30
+ reason: 'process-management-unavailable';
31
+ response: 'ephemeral';
32
+ };
33
+ /**
34
+ * Refuse any host process action. The optional control surface exists only
35
+ * so tests can prove the refusal never reaches for it.
36
+ */
37
+ export declare function planProcessAction(request: {
38
+ action: 'start' | 'stop' | 'restart' | 'upgrade';
39
+ }, deps?: {
40
+ processControl?: (...args: never[]) => unknown;
41
+ }): Promise<ProcessActionResult>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `/host status` and the process-management refusal (design.md Non-Goals,
3
+ * task 10.5). The adapter reports connectivity and version metadata and does
4
+ * nothing else to its host: start/stop/restart/upgrade requests refuse
5
+ * explicitly and ephemerally — the embedded adapter cannot manage the
6
+ * process it lives in.
7
+ */
8
+ export async function hostStatus(port) {
9
+ const status = await port.status();
10
+ if (status.outcome === 'failed')
11
+ return { outcome: 'failed', reason: 'host-status-unavailable' };
12
+ if (status.outcome === 'unknown')
13
+ return { outcome: 'failed', reason: 'host-status-unknown' };
14
+ return { outcome: 'ok', connected: status.connected, version: status.version };
15
+ }
16
+ /**
17
+ * Refuse any host process action. The optional control surface exists only
18
+ * so tests can prove the refusal never reaches for it.
19
+ */
20
+ export function planProcessAction(request, deps = {}) {
21
+ void request;
22
+ void deps;
23
+ return Promise.resolve({ outcome: 'refused', reason: 'process-management-unavailable', response: 'ephemeral' });
24
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Bounded image collection (design.md §12, task 12.2). Every download is
3
+ * bounded four ways: the DECLARED size is checked before any fetch, the
4
+ * ACTUAL byte count of the response body is checked against the aggregate
5
+ * cap before the image is accepted, an aggregate cap spans the whole
6
+ * message, and a timeout stops a hung transfer. The HTTP port hands back one
7
+ * complete body, so peak memory for a single image is that body's size —
8
+ * bounded in practice by Discord CDN attachment limits and the aggregate
9
+ * cap, not by streaming. Refusals are plain values.
10
+ */
11
+ /** Per-image cap (design: bounded by DSH-advertised limits; adapter-capped). */
12
+ export declare const MAX_IMAGE_BYTES: number;
13
+ /** Aggregate cap across all images of one message. */
14
+ export declare const MAX_AGGREGATE_IMAGE_BYTES: number;
15
+ export interface ImageDownloadPort {
16
+ download(request: {
17
+ url: string;
18
+ }): Promise<{
19
+ outcome: 'downloaded';
20
+ mediaType: string;
21
+ body: Uint8Array;
22
+ } | {
23
+ outcome: 'unsupported-media-type';
24
+ } | {
25
+ outcome: 'rejected-host';
26
+ } | {
27
+ outcome: 'malformed-url';
28
+ } | {
29
+ outcome: 'too-many-redirects';
30
+ } | {
31
+ outcome: 'http-error';
32
+ status: number;
33
+ }>;
34
+ }
35
+ export interface ImageAttachment {
36
+ url: string;
37
+ declaredSize: number;
38
+ contentType: string;
39
+ }
40
+ export type ImageCollectionResult = {
41
+ outcome: 'collected';
42
+ images: number;
43
+ totalBytes: number;
44
+ } | {
45
+ outcome: 'too-large';
46
+ reason: 'declared' | 'actual' | 'aggregate';
47
+ } | {
48
+ outcome: 'timeout';
49
+ } | {
50
+ outcome: 'download-failed';
51
+ };
52
+ export declare function collectImages(port: ImageDownloadPort, request: {
53
+ nowMs: number;
54
+ timeoutMs: number;
55
+ attachments: ReadonlyArray<ImageAttachment>;
56
+ }): Promise<ImageCollectionResult>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Bounded image collection (design.md §12, task 12.2). Every download is
3
+ * bounded four ways: the DECLARED size is checked before any fetch, the
4
+ * ACTUAL byte count of the response body is checked against the aggregate
5
+ * cap before the image is accepted, an aggregate cap spans the whole
6
+ * message, and a timeout stops a hung transfer. The HTTP port hands back one
7
+ * complete body, so peak memory for a single image is that body's size —
8
+ * bounded in practice by Discord CDN attachment limits and the aggregate
9
+ * cap, not by streaming. Refusals are plain values.
10
+ */
11
+ /** Per-image cap (design: bounded by DSH-advertised limits; adapter-capped). */
12
+ export const MAX_IMAGE_BYTES = 8 * 1_024 * 1_024;
13
+ /** Aggregate cap across all images of one message. */
14
+ export const MAX_AGGREGATE_IMAGE_BYTES = 24 * 1_024 * 1_024;
15
+ /**
16
+ * Wrap the safe download boundary with byte-count enforcement: the body size
17
+ * is checked against the remaining aggregate budget before it is accepted,
18
+ * so memory stays bounded no matter what the peer sends.
19
+ */
20
+ function boundedDownloadPort(inner, state) {
21
+ return {
22
+ download: async (request) => {
23
+ const result = await inner.download(request);
24
+ if (result.outcome !== 'downloaded')
25
+ return result;
26
+ if (result.body.byteLength > state.remainingBytes) {
27
+ return { outcome: 'http-error', status: 413 };
28
+ }
29
+ state.remainingBytes -= result.body.byteLength;
30
+ return result;
31
+ },
32
+ };
33
+ }
34
+ export function collectImages(port, request) {
35
+ let remainingAggregate = MAX_AGGREGATE_IMAGE_BYTES;
36
+ let images = 0;
37
+ let totalBytes = 0;
38
+ const inner = port;
39
+ async function run() {
40
+ for (const attachment of request.attachments) {
41
+ // Declared size: checked BEFORE the fetch.
42
+ if (attachment.declaredSize > MAX_IMAGE_BYTES) {
43
+ return { outcome: 'too-large', reason: 'declared' };
44
+ }
45
+ if (attachment.declaredSize > remainingAggregate) {
46
+ return { outcome: 'too-large', reason: 'aggregate' };
47
+ }
48
+ const bounded = boundedDownloadPort(inner, { remainingBytes: Math.min(MAX_IMAGE_BYTES, remainingAggregate) });
49
+ let timer;
50
+ const timeout = new Promise((resolve) => {
51
+ timer = setTimeout(() => { resolve('timeout'); }, request.timeoutMs);
52
+ });
53
+ const downloaded = await Promise.race([
54
+ bounded.download({ url: attachment.url }).then((result) => result),
55
+ timeout,
56
+ ]).finally(() => {
57
+ if (timer !== undefined)
58
+ clearTimeout(timer);
59
+ });
60
+ if (downloaded === 'timeout')
61
+ return { outcome: 'timeout' };
62
+ if (downloaded.outcome === 'downloaded') {
63
+ if (downloaded.body.byteLength > MAX_IMAGE_BYTES) {
64
+ return { outcome: 'too-large', reason: 'actual' };
65
+ }
66
+ images += 1;
67
+ totalBytes += downloaded.body.byteLength;
68
+ remainingAggregate -= downloaded.body.byteLength;
69
+ continue;
70
+ }
71
+ if (downloaded.outcome === 'http-error' && downloaded.status === 413) {
72
+ return { outcome: 'too-large', reason: 'actual' };
73
+ }
74
+ return { outcome: 'download-failed' };
75
+ }
76
+ return { outcome: 'collected', images, totalBytes };
77
+ }
78
+ return run();
79
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The safe image download boundary (design.md §12, task 12.1). Images enter
3
+ * only from allowlisted Discord CDN hosts over HTTPS with supported media
4
+ * types. Redirects are followed at most ONE hop and every hop revalidates
5
+ * against the same host/protocol rules — an open redirect cannot leak the
6
+ * bot's fetch authority to a foreign server. Every rejection is a value
7
+ * decided BEFORE any network activity where possible.
8
+ */
9
+ export type ImageUrlValidation = {
10
+ ok: true;
11
+ url: URL;
12
+ } | {
13
+ ok: false;
14
+ };
15
+ /**
16
+ * Validate an untrusted image URL: HTTPS, an exact allowlisted host, and a
17
+ * parseable URL. Host matching is exact — suffix lookalikes
18
+ * (`cdn.discordapp.com.evil.com`) refuse.
19
+ */
20
+ export declare function validateImageUrl(rawUrl: string): ImageUrlValidation;
21
+ /** The fetch surface the downloader uses (no redirect following built in). */
22
+ export interface HttpFetchPort {
23
+ fetch(url: string): Promise<{
24
+ status: number;
25
+ contentType: string | undefined;
26
+ location?: string | undefined;
27
+ body?: Uint8Array | undefined;
28
+ }>;
29
+ }
30
+ export type SafeImageResult = {
31
+ outcome: 'downloaded';
32
+ mediaType: string;
33
+ body: Uint8Array;
34
+ } | {
35
+ outcome: 'malformed-url';
36
+ } | {
37
+ outcome: 'rejected-host';
38
+ } | {
39
+ outcome: 'unsupported-media-type';
40
+ } | {
41
+ outcome: 'too-many-redirects';
42
+ } | {
43
+ outcome: 'http-error';
44
+ status: number;
45
+ };
46
+ export declare function fetchSafeImage(port: HttpFetchPort, request: {
47
+ url: string;
48
+ }): Promise<SafeImageResult>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The safe image download boundary (design.md §12, task 12.1). Images enter
3
+ * only from allowlisted Discord CDN hosts over HTTPS with supported media
4
+ * types. Redirects are followed at most ONE hop and every hop revalidates
5
+ * against the same host/protocol rules — an open redirect cannot leak the
6
+ * bot's fetch authority to a foreign server. Every rejection is a value
7
+ * decided BEFORE any network activity where possible.
8
+ */
9
+ const ALLOWED_HOSTS = new Set([
10
+ 'cdn.discordapp.com',
11
+ 'media.discordapp.net',
12
+ ]);
13
+ const SUPPORTED_MEDIA_TYPES = new Set([
14
+ 'image/jpeg',
15
+ 'image/png',
16
+ 'image/webp',
17
+ 'image/gif',
18
+ ]);
19
+ const MAX_REDIRECTS = 1;
20
+ /**
21
+ * Validate an untrusted image URL: HTTPS, an exact allowlisted host, and a
22
+ * parseable URL. Host matching is exact — suffix lookalikes
23
+ * (`cdn.discordapp.com.evil.com`) refuse.
24
+ */
25
+ export function validateImageUrl(rawUrl) {
26
+ let url;
27
+ try {
28
+ url = new URL(rawUrl);
29
+ }
30
+ catch {
31
+ return { ok: false };
32
+ }
33
+ if (url.protocol !== 'https:')
34
+ return { ok: false };
35
+ if (!ALLOWED_HOSTS.has(url.hostname))
36
+ return { ok: false };
37
+ return { ok: true, url };
38
+ }
39
+ async function fetchOnce(port, hopsLeft, request) {
40
+ const validation = validateImageUrl(request.url);
41
+ if (!validation.ok) {
42
+ return isParseable(request.url) ? { outcome: 'rejected-host' } : { outcome: 'malformed-url' };
43
+ }
44
+ const response = await port.fetch(request.url);
45
+ if (response.status >= 300 && response.status < 400) {
46
+ if (hopsLeft === 0)
47
+ return { outcome: 'too-many-redirects' };
48
+ const location = response.location;
49
+ if (location === undefined)
50
+ return { outcome: 'http-error', status: response.status };
51
+ return fetchOnce(port, hopsLeft - 1, { url: location });
52
+ }
53
+ if (response.status !== 200) {
54
+ return { outcome: 'http-error', status: response.status };
55
+ }
56
+ const contentType = (response.contentType ?? '').split(';')[0]?.trim().toLowerCase() ?? '';
57
+ if (!SUPPORTED_MEDIA_TYPES.has(contentType)) {
58
+ return { outcome: 'unsupported-media-type' };
59
+ }
60
+ return {
61
+ outcome: 'downloaded',
62
+ mediaType: contentType,
63
+ body: response.body ?? new Uint8Array(),
64
+ };
65
+ }
66
+ function isParseable(rawUrl) {
67
+ try {
68
+ void new URL(rawUrl);
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ export function fetchSafeImage(port, request) {
76
+ return fetchOnce(port, MAX_REDIRECTS, request);
77
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Image submission through `session.prompt` (design.md §12, task 12.3).
3
+ * Mixed text/images encode into ordered prompt parts; the model's modality
4
+ * is checked BEFORE any DSH call; and the request id claims an intent so a
5
+ * duplicate Discord delivery never resubmits. Host outcomes map to values
6
+ * with the intent state kept honest (failed on rejection, unknown preserved
7
+ * for reconciliation).
8
+ */
9
+ import { type IntentStore } from '../state/intents.js';
10
+ export type PromptPart = {
11
+ type: 'text';
12
+ text: string;
13
+ } | {
14
+ type: 'image';
15
+ mediaType: string;
16
+ data: string;
17
+ };
18
+ export interface DshImagePromptPort {
19
+ submit(request: {
20
+ requestId: string;
21
+ sessionId: string;
22
+ parts: readonly PromptPart[];
23
+ }): Promise<{
24
+ outcome: 'accepted';
25
+ } | {
26
+ outcome: 'rejected';
27
+ } | {
28
+ outcome: 'unknown';
29
+ }>;
30
+ }
31
+ export interface ImageSubmissionDeps {
32
+ prompts: DshImagePromptPort;
33
+ intents: IntentStore;
34
+ /** Whether the session's current model accepts image parts. */
35
+ modality: () => boolean;
36
+ nowMs: () => number;
37
+ }
38
+ export type ImageSubmissionResult = {
39
+ outcome: 'accepted';
40
+ } | {
41
+ outcome: 'rejected';
42
+ } | {
43
+ outcome: 'unknown';
44
+ } | {
45
+ outcome: 'already-submitted';
46
+ } | {
47
+ outcome: 'conflict';
48
+ } | {
49
+ outcome: 'refused';
50
+ reason: 'unsupported-modality';
51
+ };
52
+ /** Encode text plus images into ordered prompt parts. */
53
+ export declare function encodePromptParts(text: string, images: ReadonlyArray<{
54
+ mediaType: string;
55
+ base64: string;
56
+ }>): PromptPart[];
57
+ export declare function createImageSubmissionFlow(deps: ImageSubmissionDeps): {
58
+ submit(request: {
59
+ requestId: string;
60
+ sessionId: string;
61
+ text: string;
62
+ images: ReadonlyArray<{
63
+ mediaType: string;
64
+ base64: string;
65
+ }>;
66
+ }): Promise<ImageSubmissionResult>;
67
+ };