@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,50 @@
1
+ /**
2
+ * History replay after reconnect (design.md §11 steps 4–7, tasks 15.3/15.4).
3
+ * Because DSH 0.1.1-rc.2 ignores `events.mux.since`, durable history reads —
4
+ * not the live stream — are the recovery source. Per active session the
5
+ * replay walks bounded history pages, folds events past the committed
6
+ * watermark oldest-first, skips events whose live delivery is already known,
7
+ * and advances the watermark strictly after the delivery bookkeeping for that
8
+ * event resolves. The page budget bounds recovery work; hitting it yields an
9
+ * explicitly incomplete result rather than an unbounded fetch loop.
10
+ */
11
+ const DEFAULT_MAX_PAGES = 3;
12
+ export async function replayHistory(deps, input) {
13
+ const maxPages = input.maxPages ?? DEFAULT_MAX_PAGES;
14
+ const watermark = deps.watermarkStore.watermark(input.sessionId);
15
+ const delivered = [];
16
+ let committedThrough = undefined;
17
+ // Newest page first; each older page continues below the lowest seq seen.
18
+ let beforeSeq = undefined;
19
+ let pending = [];
20
+ let incomplete = false;
21
+ for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) {
22
+ const page = await deps.historyPort.history(input.sessionId, beforeSeq);
23
+ const fresh = page.events.filter(event => event.seq > (watermark ?? Number.NEGATIVE_INFINITY));
24
+ pending = [...fresh, ...pending];
25
+ beforeSeq = page.events.length > 0 ? page.events[0]?.seq : beforeSeq;
26
+ if (!page.hasMore) {
27
+ pending = pending.filter(event => event.seq > (watermark ?? Number.NEGATIVE_INFINITY));
28
+ break;
29
+ }
30
+ if (pageIndex === maxPages - 1)
31
+ incomplete = true;
32
+ }
33
+ // Fold oldest-first, committing only after the bookkeeping resolves.
34
+ // Events already delivered live across the gap (their id is in liveSeen)
35
+ // skip the deliver call entirely but still advance the watermark: the
36
+ // live path owns their bookkeeping.
37
+ for (const event of pending) {
38
+ if (input.liveSeen?.has(event.id) === true) {
39
+ await deps.watermarkStore.commit(input.sessionId, event.seq);
40
+ committedThrough = event.seq;
41
+ continue;
42
+ }
43
+ const bookkeeping = await deps.deliver.deliver(event);
44
+ if (bookkeeping === 'recorded')
45
+ delivered.push(event.id);
46
+ await deps.watermarkStore.commit(input.sessionId, event.seq);
47
+ committedThrough = event.seq;
48
+ }
49
+ return { delivered, committedThrough, incomplete };
50
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Uncertain prompt admission reconciliation (design.md §11 step 6, task
3
+ * 15.5). An intent parked in `unknown` is resolved by evidence, never by
4
+ * resubmission: a durable history entry whose `user/message.source.rpcId`
5
+ * equals the submitted request ID proves DSH accepted the prompt, and the
6
+ * live queue snapshot still holding the pending item proves it too. Without
7
+ * evidence — or with probes that could not complete — the intent stays
8
+ * unknown and the user's explicit retry mints a NEW intent, exactly as the
9
+ * submission flow promises.
10
+ */
11
+ export interface IntentEvidenceInput {
12
+ messageId: string;
13
+ sessionId: string;
14
+ requestId: string;
15
+ }
16
+ export type EvidenceProbe = (sessionId: string, requestId: string) => Promise<'present' | 'absent' | 'unknown'>;
17
+ export interface IntentReconcileDeps {
18
+ resolve(messageId: string, outcome: 'succeeded' | 'failed' | 'unknown', atMs: number): Promise<void>;
19
+ historyEvidence: EvidenceProbe;
20
+ queueEvidence: EvidenceProbe;
21
+ nowMs: () => number;
22
+ }
23
+ export interface IntentReconcileInput {
24
+ intents: readonly IntentEvidenceInput[];
25
+ /** Bound per sweep; leftovers wait for the next one. */
26
+ maxIntents: number;
27
+ }
28
+ export interface IntentReconcileResult {
29
+ proven: string[];
30
+ unresolved: string[];
31
+ }
32
+ export declare function reconcileIntents(deps: IntentReconcileDeps, input: IntentReconcileInput): Promise<IntentReconcileResult>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Uncertain prompt admission reconciliation (design.md §11 step 6, task
3
+ * 15.5). An intent parked in `unknown` is resolved by evidence, never by
4
+ * resubmission: a durable history entry whose `user/message.source.rpcId`
5
+ * equals the submitted request ID proves DSH accepted the prompt, and the
6
+ * live queue snapshot still holding the pending item proves it too. Without
7
+ * evidence — or with probes that could not complete — the intent stays
8
+ * unknown and the user's explicit retry mints a NEW intent, exactly as the
9
+ * submission flow promises.
10
+ */
11
+ export async function reconcileIntents(deps, input) {
12
+ const proven = [];
13
+ const unresolved = [];
14
+ for (const intent of input.intents.slice(0, input.maxIntents)) {
15
+ const inHistory = await deps.historyEvidence(intent.sessionId, intent.requestId);
16
+ if (inHistory === 'present') {
17
+ await deps.resolve(intent.messageId, 'succeeded', deps.nowMs());
18
+ proven.push(intent.messageId);
19
+ continue;
20
+ }
21
+ const inQueue = await deps.queueEvidence(intent.sessionId, intent.requestId);
22
+ if (inQueue === 'present') {
23
+ await deps.resolve(intent.messageId, 'succeeded', deps.nowMs());
24
+ proven.push(intent.messageId);
25
+ continue;
26
+ }
27
+ // Absent or unverifiable: leave the intent exactly as it is. No
28
+ // resubmission — the user's explicit retry mints a new intent.
29
+ unresolved.push(intent.messageId);
30
+ }
31
+ return { proven, unresolved };
32
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Pending interaction reconciliation (design.md §9/§11 steps 3 and 10, task
3
+ * 15.7). At startup each pending interaction is judged against the Host's
4
+ * replayed pending baseline and generation. A replayed request keeps its
5
+ * controls; a resolution observed from another client retires them; a Host
6
+ * generation change with the interaction missing from the new baseline
7
+ * expires them fail-closed — the adapter never claims an answer was applied.
8
+ * The sweep accepts a disposal probe and stops touching anything the moment
9
+ * it turns false.
10
+ */
11
+ export interface InteractionRecord {
12
+ rpcId: string;
13
+ kind: 'approval' | 'question';
14
+ state: 'pending' | 'submitting' | 'resolved' | 'unresolved' | 'expired';
15
+ /** The Host generation the interaction was created under. */
16
+ seenGeneration: number;
17
+ /** Outcome observed from a `resolved` frame, if any. */
18
+ remoteOutcome?: 'allowed-once' | 'rejected' | 'answered' | 'cancelled' | undefined;
19
+ }
20
+ export interface InteractionFacts {
21
+ hostGeneration: number;
22
+ /** rpcIds the Host still reports pending (the mux replay baseline). */
23
+ pendingBaseline: ReadonlySet<string>;
24
+ }
25
+ export type InteractionAction = {
26
+ rpcId: string;
27
+ action: 'keep';
28
+ } | {
29
+ rpcId: string;
30
+ action: 'retire';
31
+ reason: 'resolved-elsewhere';
32
+ outcome: InteractionRecord['remoteOutcome'];
33
+ } | {
34
+ rpcId: string;
35
+ action: 'expire';
36
+ reason: 'host-generation-change';
37
+ };
38
+ export interface InteractionPlan {
39
+ actions: InteractionAction[];
40
+ }
41
+ /** Pure planning: given records and facts, decide each interaction's fate. */
42
+ export declare function planInteractionReconciliation(records: readonly InteractionRecord[], facts: InteractionFacts): InteractionPlan;
43
+ export interface InteractionReconcileDeps {
44
+ /** Retires the rendered controls for one interaction. */
45
+ disable(rpcId: string): Promise<void>;
46
+ /** Disposal probe: false stops the sweep between records. */
47
+ shouldContinue?: () => boolean | undefined;
48
+ }
49
+ export interface InteractionSweepResult {
50
+ kept: string[];
51
+ retired: string[];
52
+ expired: string[];
53
+ aborted: boolean;
54
+ }
55
+ /**
56
+ * Apply the plan: retire and expire disable the controls; keep leaves them
57
+ * alone. The sweep stops between records the moment disposal wins.
58
+ */
59
+ export declare function sweepInteractionReconciliation(deps: InteractionReconcileDeps, records: readonly InteractionRecord[], facts: InteractionFacts): Promise<InteractionSweepResult>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Pending interaction reconciliation (design.md §9/§11 steps 3 and 10, task
3
+ * 15.7). At startup each pending interaction is judged against the Host's
4
+ * replayed pending baseline and generation. A replayed request keeps its
5
+ * controls; a resolution observed from another client retires them; a Host
6
+ * generation change with the interaction missing from the new baseline
7
+ * expires them fail-closed — the adapter never claims an answer was applied.
8
+ * The sweep accepts a disposal probe and stops touching anything the moment
9
+ * it turns false.
10
+ */
11
+ /** Pure planning: given records and facts, decide each interaction's fate. */
12
+ export function planInteractionReconciliation(records, facts) {
13
+ const actions = records.map((record) => {
14
+ if (record.remoteOutcome !== undefined) {
15
+ return { rpcId: record.rpcId, action: 'retire', reason: 'resolved-elsewhere', outcome: record.remoteOutcome };
16
+ }
17
+ const replayed = facts.pendingBaseline.has(record.rpcId);
18
+ if (replayed && facts.hostGeneration === record.seenGeneration) {
19
+ return { rpcId: record.rpcId, action: 'keep' };
20
+ }
21
+ if (facts.hostGeneration !== record.seenGeneration && !replayed) {
22
+ return { rpcId: record.rpcId, action: 'expire', reason: 'host-generation-change' };
23
+ }
24
+ return { rpcId: record.rpcId, action: 'retire', reason: 'resolved-elsewhere', outcome: undefined };
25
+ });
26
+ return { actions };
27
+ }
28
+ /**
29
+ * Apply the plan: retire and expire disable the controls; keep leaves them
30
+ * alone. The sweep stops between records the moment disposal wins.
31
+ */
32
+ export async function sweepInteractionReconciliation(deps, records, facts) {
33
+ const plan = planInteractionReconciliation(records, facts);
34
+ const result = { kept: [], retired: [], expired: [], aborted: false };
35
+ for (let index = 0; index < plan.actions.length; index += 1) {
36
+ if (deps.shouldContinue?.() === false) {
37
+ result.aborted = true;
38
+ return result;
39
+ }
40
+ const action = plan.actions[index];
41
+ if (action === undefined)
42
+ continue;
43
+ if (action.action === 'keep') {
44
+ result.kept.push(action.rpcId);
45
+ continue;
46
+ }
47
+ await deps.disable(action.rpcId);
48
+ if (action.action === 'expire')
49
+ result.expired.push(action.rpcId);
50
+ else
51
+ result.retired.push(action.rpcId);
52
+ }
53
+ return result;
54
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Cold session adoption (design.md §13, task 9.2). Resuming a
3
+ * not-currently-bound session adopts it into ONE new writable Discord
4
+ * thread: ownership is claimed, thread→session is bound durably, and a
5
+ * bounded slice of recent history is shown — the model is never prompted by
6
+ * adoption itself. Subagent sessions are not adoptable; a session that
7
+ * cannot be inspected refuses before anything is created.
8
+ */
9
+ import type { BindingStore } from '../state/bindings.js';
10
+ import type { ThreadBinding } from '../state/records.js';
11
+ import type { SessionOwnerStore } from '../state/session-owners.js';
12
+ export interface InspectedSession {
13
+ sessionId: string;
14
+ workspaceId: string;
15
+ archived: boolean;
16
+ isSubagent: boolean;
17
+ history: ReadonlyArray<{
18
+ index: number;
19
+ role: string;
20
+ }>;
21
+ }
22
+ export interface DshSessionInspectPort {
23
+ inspect(request: {
24
+ sessionId: string;
25
+ }): Promise<{
26
+ outcome: 'found';
27
+ session: InspectedSession;
28
+ } | {
29
+ outcome: 'not-found';
30
+ } | {
31
+ outcome: 'failed';
32
+ } | {
33
+ outcome: 'unknown';
34
+ }>;
35
+ }
36
+ export interface DiscordThreadPort {
37
+ createThread(request: {
38
+ parentChannelId: string;
39
+ name: string;
40
+ }): Promise<{
41
+ outcome: 'completed';
42
+ threadId: string;
43
+ } | {
44
+ outcome: 'failed';
45
+ } | {
46
+ outcome: 'unknown';
47
+ }>;
48
+ findThreadBySource(sourceMessageId: string): Promise<{
49
+ outcome: 'found';
50
+ threadId: string;
51
+ } | {
52
+ outcome: 'not-found';
53
+ }>;
54
+ }
55
+ /** Bounded recent history shown on adoption (design.md §13: bounded display). */
56
+ export declare const ADOPTION_HISTORY_LIMIT = 10;
57
+ export interface SessionAdoptionDeps {
58
+ sessions: DshSessionInspectPort;
59
+ discord: DiscordThreadPort;
60
+ threadBindings: BindingStore<ThreadBinding>;
61
+ owners: SessionOwnerStore;
62
+ nowMs: () => number;
63
+ }
64
+ export type SessionAdoptionResult = {
65
+ outcome: 'adopted';
66
+ threadId: string;
67
+ sessionId: string;
68
+ workspaceId: string;
69
+ /** Recent history, oldest → newest, capped at the display limit. */
70
+ history: InspectedSession['history'];
71
+ } | {
72
+ outcome: 'refused';
73
+ reason: 'subagent-not-adoptable' | 'session-disappeared';
74
+ } | {
75
+ outcome: 'conflict';
76
+ ownedByThreadId: string;
77
+ } | {
78
+ outcome: 'failed';
79
+ } | {
80
+ outcome: 'unknown';
81
+ };
82
+ export declare function createSessionAdoptionFlow(deps: SessionAdoptionDeps): {
83
+ adopt(request: {
84
+ sessionId: string;
85
+ guildId: string;
86
+ parentChannelId: string;
87
+ createdBy: string;
88
+ }): Promise<SessionAdoptionResult>;
89
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Cold session adoption (design.md §13, task 9.2). Resuming a
3
+ * not-currently-bound session adopts it into ONE new writable Discord
4
+ * thread: ownership is claimed, thread→session is bound durably, and a
5
+ * bounded slice of recent history is shown — the model is never prompted by
6
+ * adoption itself. Subagent sessions are not adoptable; a session that
7
+ * cannot be inspected refuses before anything is created.
8
+ */
9
+ import { threadBindingKey } from '../state/domain.js';
10
+ /** Bounded recent history shown on adoption (design.md §13: bounded display). */
11
+ export const ADOPTION_HISTORY_LIMIT = 10;
12
+ export function createSessionAdoptionFlow(deps) {
13
+ return {
14
+ async adopt(request) {
15
+ const inspected = await deps.sessions.inspect({ sessionId: request.sessionId });
16
+ if (inspected.outcome === 'not-found') {
17
+ return { outcome: 'refused', reason: 'session-disappeared' };
18
+ }
19
+ if (inspected.outcome === 'failed')
20
+ return { outcome: 'failed' };
21
+ if (inspected.outcome === 'unknown')
22
+ return { outcome: 'unknown' };
23
+ if (inspected.session.isSubagent) {
24
+ return { outcome: 'refused', reason: 'subagent-not-adoptable' };
25
+ }
26
+ // Claim ownership BEFORE creating the thread: a session already owned
27
+ // by another thread conflicts explicitly — never an implicit takeover.
28
+ const ownership = await deps.owners.claim({
29
+ sessionId: request.sessionId,
30
+ threadId: `pending:${request.sessionId}`,
31
+ guildId: request.guildId,
32
+ claimedAtMs: deps.nowMs(),
33
+ });
34
+ if (ownership.outcome === 'conflict') {
35
+ return { outcome: 'conflict', ownedByThreadId: ownership.record.threadId };
36
+ }
37
+ const owningThread = ownership.outcome === 'claimed'
38
+ ? ownership.record.threadId
39
+ : ownership.record.threadId;
40
+ const created = await deps.discord.createThread({
41
+ parentChannelId: request.parentChannelId,
42
+ name: `Resume: ${request.sessionId}`,
43
+ });
44
+ if (created.outcome !== 'completed') {
45
+ // Release the pending ownership so a retry can claim cleanly.
46
+ await deps.owners.release({ sessionId: request.sessionId, threadId: owningThread });
47
+ return created.outcome === 'failed' ? { outcome: 'failed' } : { outcome: 'unknown' };
48
+ }
49
+ // Re-key ownership from the pending marker to the real thread id.
50
+ await deps.owners.release({ sessionId: request.sessionId, threadId: owningThread });
51
+ const owned = await deps.owners.claim({
52
+ sessionId: request.sessionId,
53
+ threadId: created.threadId,
54
+ guildId: request.guildId,
55
+ claimedAtMs: deps.nowMs(),
56
+ });
57
+ if (owned.outcome === 'conflict') {
58
+ return { outcome: 'failed' };
59
+ }
60
+ const bound = await deps.threadBindings.bind(threadBindingKey({ applicationId: 'app', guildId: request.guildId, threadId: created.threadId }), {
61
+ sessionId: request.sessionId,
62
+ workspaceId: inspected.session.workspaceId,
63
+ createdBy: request.createdBy,
64
+ createdAtMs: deps.nowMs(),
65
+ });
66
+ if (!bound.ok)
67
+ return { outcome: 'failed' };
68
+ return {
69
+ outcome: 'adopted',
70
+ threadId: created.threadId,
71
+ sessionId: request.sessionId,
72
+ workspaceId: inspected.session.workspaceId,
73
+ history: inspected.session.history.slice(-ADOPTION_HISTORY_LIMIT),
74
+ };
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * DSH session creation for a new thread (design.md §10, task 8.3). The
3
+ * adapter preallocates the session id and hands it to `session.create`, so
4
+ * DSH can adopt the same id idempotently after an uncertain response. Success
5
+ * binds the thread durably (single writable owner); a bound thread replays
6
+ * its existing session without any DSH call; rejection and unknown outcomes
7
+ * leave the thread unbound — reconciliation, never blind retry, decides what
8
+ * actually happened.
9
+ */
10
+ import { type ThreadBindingScope } from '../state/domain.js';
11
+ import type { BindingStore } from '../state/bindings.js';
12
+ import type { ThreadBinding } from '../state/records.js';
13
+ /** The DSH session-creation surface the flow needs. */
14
+ export interface DshSessionPort {
15
+ createSession(request: {
16
+ sessionId: string;
17
+ workspaceId: string;
18
+ }): Promise<{
19
+ outcome: 'completed';
20
+ sessionId: string;
21
+ } | {
22
+ outcome: 'rejected';
23
+ reason: string;
24
+ } | {
25
+ outcome: 'unknown';
26
+ }>;
27
+ }
28
+ export interface SessionCreationDeps {
29
+ sessions: DshSessionPort;
30
+ threadBindings: BindingStore<ThreadBinding>;
31
+ /** Preallocation source for session ids (design.md §10). */
32
+ newSessionId: () => string;
33
+ }
34
+ export type SessionCreationResult = {
35
+ outcome: 'created';
36
+ sessionId: string;
37
+ } | {
38
+ outcome: 'rejected';
39
+ } | {
40
+ outcome: 'unknown';
41
+ };
42
+ export declare function createSessionCreationFlow(deps: SessionCreationDeps): {
43
+ ensureSession(request: {
44
+ scope: ThreadBindingScope;
45
+ workspaceId: string;
46
+ createdBy: string;
47
+ nowMs: number;
48
+ }): Promise<SessionCreationResult>;
49
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * DSH session creation for a new thread (design.md §10, task 8.3). The
3
+ * adapter preallocates the session id and hands it to `session.create`, so
4
+ * DSH can adopt the same id idempotently after an uncertain response. Success
5
+ * binds the thread durably (single writable owner); a bound thread replays
6
+ * its existing session without any DSH call; rejection and unknown outcomes
7
+ * leave the thread unbound — reconciliation, never blind retry, decides what
8
+ * actually happened.
9
+ */
10
+ import { threadBindingKey } from '../state/domain.js';
11
+ export function createSessionCreationFlow(deps) {
12
+ return {
13
+ async ensureSession(request) {
14
+ const key = threadBindingKey(request.scope);
15
+ // Replay or crash-recovery: an existing binding IS the answer.
16
+ const existing = deps.threadBindings.get(key);
17
+ if (existing !== undefined) {
18
+ return { outcome: 'created', sessionId: existing.sessionId };
19
+ }
20
+ const preallocatedId = deps.newSessionId();
21
+ const created = await deps.sessions.createSession({
22
+ sessionId: preallocatedId,
23
+ workspaceId: request.workspaceId,
24
+ });
25
+ if (created.outcome === 'completed') {
26
+ const bound = await deps.threadBindings.bind(key, {
27
+ sessionId: created.sessionId,
28
+ workspaceId: request.workspaceId,
29
+ createdBy: request.createdBy,
30
+ createdAtMs: request.nowMs,
31
+ });
32
+ if (!bound.ok) {
33
+ // Lost a bind race to a concurrent creator: its session wins.
34
+ const winner = deps.threadBindings.get(key);
35
+ return { outcome: 'created', sessionId: winner?.sessionId ?? created.sessionId };
36
+ }
37
+ return { outcome: 'created', sessionId: created.sessionId };
38
+ }
39
+ // Rejection and unknown: stay unbound, report the outcome as a value.
40
+ return created.outcome === 'rejected' ? { outcome: 'rejected' } : { outcome: 'unknown' };
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The session mainline (Phase 1 composition): the one orchestrator both
3
+ * Discord ingress paths share. `admitMention` walks a bound-channel mention
4
+ * through intent claim → thread creation → session creation → at-most-once
5
+ * prompt → adapter-owned turn registration; `continueInThread` queues every
6
+ * later Thread message through the same idempotent prompt flow. Each step's
7
+ * outcome surfaces as a value so the Discord layer can answer honestly —
8
+ * nothing here retries or invents a fourth state.
9
+ */
10
+ import type { createThreadCreationFlow } from './thread-creation.js';
11
+ import type { createSessionCreationFlow } from './session-creation.js';
12
+ import type { createPromptSubmissionFlow } from './prompt-submission.js';
13
+ import type { TurnTracker } from './turn-ownership.js';
14
+ export interface SessionMainlineDeps {
15
+ threads: ReturnType<typeof createThreadCreationFlow>;
16
+ sessions: ReturnType<typeof createSessionCreationFlow>;
17
+ prompts: ReturnType<typeof createPromptSubmissionFlow>;
18
+ turns: TurnTracker;
19
+ }
20
+ export type MentionMainlineResult = {
21
+ outcome: 'admitted';
22
+ threadId: string;
23
+ sessionId: string;
24
+ } | {
25
+ outcome: 'thread-conflict';
26
+ } | {
27
+ outcome: 'thread-failed';
28
+ } | {
29
+ outcome: 'session-rejected';
30
+ } | {
31
+ outcome: 'session-unknown';
32
+ } | {
33
+ outcome: 'prompt-rejected';
34
+ } | {
35
+ outcome: 'prompt-unknown';
36
+ };
37
+ export interface MentionMainlineRequest {
38
+ applicationId: string;
39
+ guildId: string;
40
+ /** The bound project channel the mention arrived in. */
41
+ channelId: string;
42
+ messageId: string;
43
+ authorId: string;
44
+ workspaceId: string;
45
+ prompt: string;
46
+ }
47
+ export type ContinuationMainlineResult = {
48
+ outcome: 'queued';
49
+ } | {
50
+ outcome: 'already-submitted';
51
+ } | {
52
+ outcome: 'conflict';
53
+ } | {
54
+ outcome: 'rejected';
55
+ } | {
56
+ outcome: 'unknown';
57
+ };
58
+ export interface ContinuationMainlineRequest {
59
+ applicationId: string;
60
+ guildId: string;
61
+ threadId: string;
62
+ sessionId: string;
63
+ messageId: string;
64
+ prompt: string;
65
+ }
66
+ /**
67
+ * The stable, adapter-owned request id for a submitted prompt: derived from
68
+ * the Discord message id, recorded on the intent before the DSH call, and
69
+ * echoed by the Host onto the durable `user/message` (`source.rpcId`).
70
+ */
71
+ export declare function requestIdFor(messageId: string): string;
72
+ export declare function createSessionMainline(deps: SessionMainlineDeps): {
73
+ admitMention(request: MentionMainlineRequest): Promise<MentionMainlineResult>;
74
+ continueInThread(request: ContinuationMainlineRequest): Promise<ContinuationMainlineResult>;
75
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The session mainline (Phase 1 composition): the one orchestrator both
3
+ * Discord ingress paths share. `admitMention` walks a bound-channel mention
4
+ * through intent claim → thread creation → session creation → at-most-once
5
+ * prompt → adapter-owned turn registration; `continueInThread` queues every
6
+ * later Thread message through the same idempotent prompt flow. Each step's
7
+ * outcome surfaces as a value so the Discord layer can answer honestly —
8
+ * nothing here retries or invents a fourth state.
9
+ */
10
+ import { hashPayload } from '../state/intents.js';
11
+ import { safeTitle } from '../policy/disclosure.js';
12
+ /**
13
+ * The stable, adapter-owned request id for a submitted prompt: derived from
14
+ * the Discord message id, recorded on the intent before the DSH call, and
15
+ * echoed by the Host onto the durable `user/message` (`source.rpcId`).
16
+ */
17
+ export function requestIdFor(messageId) {
18
+ return `discord:${messageId}`;
19
+ }
20
+ /** Register the turn after an accepted submission; a second claim is a no-op. */
21
+ function ownTurn(deps, turn) {
22
+ deps.turns.register(turn);
23
+ }
24
+ function mapSubmission(result) {
25
+ if (result.outcome === 'accepted')
26
+ return { outcome: 'queued' };
27
+ if (result.outcome === 'already-submitted')
28
+ return { outcome: 'already-submitted' };
29
+ if (result.outcome === 'conflict')
30
+ return { outcome: 'conflict' };
31
+ return result.outcome === 'rejected' ? { outcome: 'rejected' } : { outcome: 'unknown' };
32
+ }
33
+ export function createSessionMainline(deps) {
34
+ return {
35
+ async admitMention(request) {
36
+ // The source message id is the durable intent: one thread per message,
37
+ // deterministic recovery on redelivery (design.md §4, §10).
38
+ const thread = await deps.threads.ensureThread({
39
+ sourceMessageId: request.messageId,
40
+ contentHash: await hashPayload({ prompt: request.prompt }),
41
+ guildId: request.guildId,
42
+ parentChannelId: request.channelId,
43
+ threadName: safeTitle(request.prompt),
44
+ creatorUserId: request.authorId,
45
+ });
46
+ if (thread.outcome === 'conflict')
47
+ return { outcome: 'thread-conflict' };
48
+ if (thread.outcome !== 'created' && thread.outcome !== 'recovered') {
49
+ return { outcome: 'thread-failed' };
50
+ }
51
+ const threadId = thread.threadId;
52
+ const scope = { applicationId: request.applicationId, guildId: request.guildId, threadId };
53
+ const session = await deps.sessions.ensureSession({
54
+ scope,
55
+ workspaceId: request.workspaceId,
56
+ createdBy: request.authorId,
57
+ nowMs: Date.now(),
58
+ });
59
+ if (session.outcome === 'rejected')
60
+ return { outcome: 'session-rejected' };
61
+ if (session.outcome === 'unknown')
62
+ return { outcome: 'session-unknown' };
63
+ const sessionId = session.sessionId;
64
+ const requestId = requestIdFor(request.messageId);
65
+ const submitted = await deps.prompts.submitOnce({
66
+ requestId,
67
+ sessionId,
68
+ prompt: request.prompt,
69
+ });
70
+ if (submitted.outcome === 'accepted') {
71
+ ownTurn(deps, { sessionId, requestId, threadId });
72
+ return { outcome: 'admitted', threadId, sessionId };
73
+ }
74
+ // A replay lands here with `already-submitted`: the binding and thread
75
+ // still exist, so the visible state is identical to the first admit.
76
+ if (submitted.outcome === 'already-submitted') {
77
+ return { outcome: 'admitted', threadId, sessionId };
78
+ }
79
+ if (submitted.outcome === 'conflict')
80
+ return { outcome: 'thread-conflict' };
81
+ return submitted.outcome === 'rejected' ? { outcome: 'prompt-rejected' } : { outcome: 'prompt-unknown' };
82
+ },
83
+ async continueInThread(request) {
84
+ const requestId = requestIdFor(request.messageId);
85
+ const submitted = await deps.prompts.submitOnce({
86
+ requestId,
87
+ sessionId: request.sessionId,
88
+ prompt: request.prompt,
89
+ });
90
+ if (submitted.outcome === 'accepted') {
91
+ ownTurn(deps, { sessionId: request.sessionId, requestId, threadId: request.threadId });
92
+ }
93
+ return mapSubmission(submitted);
94
+ },
95
+ };
96
+ }