@addozhang/dsh-discord 0.2.3 → 0.4.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.
@@ -52,19 +52,41 @@ export function createInteractionRouter(deps) {
52
52
  });
53
53
  }
54
54
  async function routeAutocomplete(event, interactionToken) {
55
- if (event.commandName !== 'project')
55
+ if (event.commandName !== 'project' && event.commandName !== 'session')
56
56
  return;
57
57
  // Autocomplete has no deferred-ack form: whatever happens below, Discord
58
58
  // must receive exactly one type-8 answer, even if only empty choices.
59
59
  try {
60
60
  const decision = authorize(event);
61
61
  const choices = [];
62
- if (decision.allowed) {
62
+ const wireOptions = event.data['options'];
63
+ const sub = Array.isArray(wireOptions) ? wireOptions[0] : undefined;
64
+ const focused = Array.isArray(sub?.options) ? sub.options.find(option => option.focused === true) : undefined;
65
+ const query = typeof focused?.value === 'string' ? focused.value : '';
66
+ if (decision.allowed && event.commandName === 'session') {
67
+ // /session resume: live candidates scoped to the bound workspace.
68
+ try {
69
+ const binding = deps.channelBinding(event.guildId, event.channelId);
70
+ const catalog = await deps.catalogPort.listWorkspaces();
71
+ const workspacePath = binding !== undefined && catalog.outcome === 'completed'
72
+ ? catalog.workspaces.find(workspace => workspace.id === binding.workspaceId)?.path
73
+ : undefined;
74
+ const archivedSessionIds = catalog.outcome === 'completed'
75
+ ? new Set(catalog.archivedSessionIds)
76
+ : undefined;
77
+ const outcome = await deps.resumeCandidates(query, workspacePath, archivedSessionIds);
78
+ if (outcome.outcome === 'ok') {
79
+ choices.push(...outcome.options.slice(0, 25).map(option => (option.description === undefined
80
+ ? { name: option.label, value: option.value }
81
+ : { name: option.label, value: option.value, description: option.description })));
82
+ }
83
+ }
84
+ catch (cause) {
85
+ deps.warn('discord_autocomplete_sessions_failed', String(cause));
86
+ }
87
+ }
88
+ else if (decision.allowed && event.commandName === 'project') {
63
89
  try {
64
- const wireOptions = event.data['options'];
65
- const sub = Array.isArray(wireOptions) ? wireOptions[0] : undefined;
66
- const focused = Array.isArray(sub?.options) ? sub.options.find(option => option.focused === true) : undefined;
67
- const query = typeof focused?.value === 'string' ? focused.value : '';
68
90
  const catalog = await deps.catalogPort.listWorkspaces();
69
91
  if (catalog.outcome === 'completed') {
70
92
  choices.push(...workspaceAutocompleteChoices(catalog.workspaces, query).slice(0, 25));
@@ -483,6 +505,57 @@ export function createInteractionRouter(deps) {
483
505
  await followUp(content);
484
506
  return;
485
507
  }
508
+ if (event.commandName === 'session') {
509
+ // /session resume: cold-adopt an existing DSH session into a new
510
+ // thread of the bound project channel (16.44). /session new is
511
+ // deliberately absent — the @mention is the new-session path.
512
+ const options = event.data['options'];
513
+ const subcommand = Array.isArray(options) ? options[0] : undefined;
514
+ if (subcommand?.name !== 'resume') {
515
+ await followUp(deps.copy.unknownSubcommand);
516
+ return;
517
+ }
518
+ const binding = deps.channelBinding(event.guildId, event.channelId);
519
+ if (binding === undefined) {
520
+ await followUp(deps.copy.sessionResumeNeedsBoundChannel);
521
+ return;
522
+ }
523
+ const wireOptions = Array.isArray(subcommand.options) ? subcommand.options : [];
524
+ const sessionId = wireOptions.find(option => option.name === 'session')?.value;
525
+ if (typeof sessionId !== 'string' || sessionId === '') {
526
+ await followUp(deps.copy.sessionResumeFailed);
527
+ return;
528
+ }
529
+ const outcome = await deps.resumeSession({
530
+ sessionId,
531
+ workspaceId: binding.workspaceId,
532
+ guildId: event.guildId,
533
+ parentChannelId: event.channelId,
534
+ actorId: event.actorId,
535
+ });
536
+ if (outcome.outcome === 'refused-control-channel') {
537
+ await followUp(deps.copy.sessionResumeControlChannel);
538
+ return;
539
+ }
540
+ if (outcome.outcome === 'refused-subagent') {
541
+ await followUp(deps.copy.sessionResumeSubagent);
542
+ return;
543
+ }
544
+ if (outcome.outcome === 'refused-archived') {
545
+ await followUp(deps.copy.sessionResumeArchived);
546
+ return;
547
+ }
548
+ if (outcome.outcome === 'already-bound') {
549
+ await followUp(deps.copy.sessionResumeAlreadyBound(outcome.threadId));
550
+ return;
551
+ }
552
+ if (outcome.outcome === 'started') {
553
+ await followUp(deps.copy.sessionResumeStarted(outcome.threadId));
554
+ return;
555
+ }
556
+ await followUp(deps.copy.sessionResumeFailed);
557
+ return;
558
+ }
486
559
  // Terminal guard: a recognized interaction type with an unhandled
487
560
  // command name must still answer, never strand on the deferred ack.
488
561
  await followUp(deps.copy.unknownSubcommand);
@@ -22,6 +22,7 @@ export interface ProjectListPort {
22
22
  title: string;
23
23
  path?: string | undefined;
24
24
  }>;
25
+ archivedSessionIds: ReadonlyArray<string>;
25
26
  } | {
26
27
  outcome: 'failed';
27
28
  } | {
@@ -15,6 +15,8 @@ export interface DshPromptPort {
15
15
  sessionId: string;
16
16
  prompt: string;
17
17
  mode: 'queue';
18
+ /** Collected images (16.50); omitted for text-only prompts so legacy shapes are untouched. */
19
+ images?: ReadonlyArray<PromptRequestImage>;
18
20
  }): Promise<{
19
21
  outcome: 'accepted';
20
22
  } | {
@@ -24,6 +26,11 @@ export interface DshPromptPort {
24
26
  outcome: 'unknown';
25
27
  }>;
26
28
  }
29
+ /** One collected image, ready for the prompt parts encoding. */
30
+ export interface PromptRequestImage {
31
+ mediaType: string;
32
+ base64: string;
33
+ }
27
34
  export interface PromptSubmissionDeps {
28
35
  prompts: DshPromptPort;
29
36
  intents: IntentStore;
@@ -44,6 +51,8 @@ export interface PromptRequest {
44
51
  requestId: string;
45
52
  sessionId: string;
46
53
  prompt: string;
54
+ /** Collected images (16.50); omitted for text-only prompts. */
55
+ images?: ReadonlyArray<PromptRequestImage>;
47
56
  }
48
57
  export declare function createPromptSubmissionFlow(deps: PromptSubmissionDeps): {
49
58
  submitOnce(request: PromptRequest): Promise<PromptSubmissionResult>;
@@ -10,7 +10,12 @@
10
10
  import { hashPayload } from '../state/intents.js';
11
11
  export function createPromptSubmissionFlow(deps) {
12
12
  async function submit(requestId, request) {
13
- const contentHash = await hashPayload({ sessionId: request.sessionId, prompt: request.prompt });
13
+ const images = request.images ?? [];
14
+ const contentHash = await hashPayload({
15
+ sessionId: request.sessionId,
16
+ prompt: request.prompt,
17
+ ...(images.length > 0 ? { images } : {}),
18
+ });
14
19
  const claim = await deps.intents.claim({
15
20
  messageId: requestId,
16
21
  contentHash,
@@ -26,6 +31,7 @@ export function createPromptSubmissionFlow(deps) {
26
31
  sessionId: request.sessionId,
27
32
  prompt: request.prompt,
28
33
  mode: 'queue',
34
+ ...(images.length > 0 ? { images } : {}),
29
35
  });
30
36
  if (submitted.outcome === 'accepted') {
31
37
  await deps.intents.resolve(requestId, 'succeeded', deps.nowMs());
@@ -9,13 +9,44 @@
9
9
  */
10
10
  import type { createThreadCreationFlow } from './thread-creation.js';
11
11
  import type { createSessionCreationFlow } from './session-creation.js';
12
- import type { createPromptSubmissionFlow } from './prompt-submission.js';
12
+ import type { createPromptSubmissionFlow, PromptRequestImage } from './prompt-submission.js';
13
13
  import type { TurnTracker } from './turn-ownership.js';
14
+ /** Declared image metadata as carried on the normalized wire message. */
15
+ export interface MainlineWireImage {
16
+ url: string;
17
+ filename: string;
18
+ declaredSize: number;
19
+ contentType: string;
20
+ }
21
+ /** One collected image, ready for submission. */
22
+ export type MainlineImage = PromptRequestImage;
23
+ /**
24
+ * The bounded collection boundary (design.md §12, 16.50): declared wire
25
+ * metadata goes in, base64-encoded images come out. The composition root
26
+ * implements it over the safe-download boundary — this layer never touches
27
+ * the network itself.
28
+ */
29
+ export interface MainlineImageCollector {
30
+ collect(request: {
31
+ attachments: ReadonlyArray<{
32
+ url: string;
33
+ declaredSize: number;
34
+ contentType: string;
35
+ }>;
36
+ }): Promise<{
37
+ outcome: 'collected';
38
+ images: ReadonlyArray<MainlineImage>;
39
+ } | {
40
+ outcome: 'failed';
41
+ reason: string;
42
+ }>;
43
+ }
14
44
  export interface SessionMainlineDeps {
15
45
  threads: ReturnType<typeof createThreadCreationFlow>;
16
46
  sessions: ReturnType<typeof createSessionCreationFlow>;
17
47
  prompts: ReturnType<typeof createPromptSubmissionFlow>;
18
48
  turns: TurnTracker;
49
+ images: MainlineImageCollector;
19
50
  }
20
51
  export type MentionMainlineResult = {
21
52
  outcome: 'admitted';
@@ -33,6 +64,9 @@ export type MentionMainlineResult = {
33
64
  outcome: 'prompt-rejected';
34
65
  } | {
35
66
  outcome: 'prompt-unknown';
67
+ } | {
68
+ outcome: 'image-failed';
69
+ reason: string;
36
70
  };
37
71
  export interface MentionMainlineRequest {
38
72
  applicationId: string;
@@ -43,6 +77,8 @@ export interface MentionMainlineRequest {
43
77
  authorId: string;
44
78
  workspaceId: string;
45
79
  prompt: string;
80
+ /** Declared image attachments (16.50); omitted for text-only mentions. */
81
+ images?: ReadonlyArray<MainlineWireImage>;
46
82
  }
47
83
  export type ContinuationMainlineResult = {
48
84
  outcome: 'queued';
@@ -54,6 +90,9 @@ export type ContinuationMainlineResult = {
54
90
  outcome: 'rejected';
55
91
  } | {
56
92
  outcome: 'unknown';
93
+ } | {
94
+ outcome: 'image-failed';
95
+ reason: string;
57
96
  };
58
97
  export interface ContinuationMainlineRequest {
59
98
  applicationId: string;
@@ -62,6 +101,8 @@ export interface ContinuationMainlineRequest {
62
101
  sessionId: string;
63
102
  messageId: string;
64
103
  prompt: string;
104
+ /** Declared image attachments (16.50); omitted for text-only messages. */
105
+ images?: ReadonlyArray<MainlineWireImage>;
65
106
  }
66
107
  /**
67
108
  * The stable, adapter-owned request id for a submitted prompt: derived from
@@ -30,17 +30,38 @@ function mapSubmission(result) {
30
30
  return { outcome: 'conflict' };
31
31
  return result.outcome === 'rejected' ? { outcome: 'rejected' } : { outcome: 'unknown' };
32
32
  }
33
+ /**
34
+ * Collect the request's declared images through the bounded boundary. A
35
+ * text-only request never touches the collector; a failed collection is a
36
+ * first-class outcome so no degraded text-only submission is sent silently.
37
+ */
38
+ async function collectRequestImages(deps, images) {
39
+ if (images.length === 0)
40
+ return { outcome: 'collected', images: [] };
41
+ const collected = await deps.images.collect({
42
+ attachments: images.map(({ url, declaredSize, contentType }) => ({ url, declaredSize, contentType })),
43
+ });
44
+ if (collected.outcome !== 'collected')
45
+ return { outcome: 'image-failed', reason: collected.reason };
46
+ return collected;
47
+ }
33
48
  export function createSessionMainline(deps) {
34
49
  return {
35
50
  async admitMention(request) {
51
+ const declaredImages = request.images ?? [];
52
+ const images = await collectRequestImages(deps, declaredImages);
53
+ if (images.outcome !== 'collected')
54
+ return images;
36
55
  // The source message id is the durable intent: one thread per message,
37
- // deterministic recovery on redelivery (design.md §4, §10).
56
+ // deterministic recovery on redelivery (design.md §4, §10). An
57
+ // image-only mention names the thread after its first image's filename.
58
+ const title = request.prompt !== '' ? request.prompt : (declaredImages[0]?.filename ?? '');
38
59
  const thread = await deps.threads.ensureThread({
39
60
  sourceMessageId: request.messageId,
40
- contentHash: await hashPayload({ prompt: request.prompt }),
61
+ contentHash: await hashPayload({ prompt: request.prompt, images: declaredImages }),
41
62
  guildId: request.guildId,
42
63
  parentChannelId: request.channelId,
43
- threadName: safeTitle(request.prompt),
64
+ threadName: safeTitle(title),
44
65
  creatorUserId: request.authorId,
45
66
  });
46
67
  if (thread.outcome === 'conflict')
@@ -66,6 +87,7 @@ export function createSessionMainline(deps) {
66
87
  requestId,
67
88
  sessionId,
68
89
  prompt: request.prompt,
90
+ ...(images.images.length > 0 ? { images: images.images } : {}),
69
91
  });
70
92
  if (submitted.outcome === 'accepted') {
71
93
  ownTurn(deps, { sessionId, requestId, threadId });
@@ -81,11 +103,15 @@ export function createSessionMainline(deps) {
81
103
  return submitted.outcome === 'rejected' ? { outcome: 'prompt-rejected' } : { outcome: 'prompt-unknown' };
82
104
  },
83
105
  async continueInThread(request) {
106
+ const images = await collectRequestImages(deps, request.images ?? []);
107
+ if (images.outcome !== 'collected')
108
+ return images;
84
109
  const requestId = requestIdFor(request.messageId);
85
110
  const submitted = await deps.prompts.submitOnce({
86
111
  requestId,
87
112
  sessionId: request.sessionId,
88
113
  prompt: request.prompt,
114
+ ...(images.images.length > 0 ? { images: images.images } : {}),
89
115
  });
90
116
  if (submitted.outcome === 'accepted') {
91
117
  ownTurn(deps, { sessionId: request.sessionId, requestId, threadId: request.threadId });
@@ -1,44 +1,78 @@
1
1
  /**
2
- * `/session resume` selector (design.md §13, task 9.1). Metadata only: the
3
- * current workspace's sessions, filtered by id/title substring — never
4
- * full-text content search (disabled by default in the Web profile).
5
- * Untitled sessions fall back to their id; archived sessions carry an
6
- * explicit mark so the user can tell before adopting.
2
+ * `/session resume` candidate building (design.md §13, tasks 9.1 + 16.44).
3
+ * The Host's `sessions.list` returns rich rows (updatedAt desc, running,
4
+ * blank, cwd, title via projection values). Candidates exclude blank
5
+ * sessions and sessions this adapter already owns a thread for, filter by
6
+ * title/id substring, and sort newest-first — Discord autocomplete caps at
7
+ * 25 choices, the caller slices.
7
8
  */
9
+ import type { SessionResumeRow } from '../dsh/api-proxy-face.js';
10
+ export type { SessionResumeRow };
11
+ import type { CopyTable } from '../i18n.js';
8
12
  import { type SelectorOption } from '../discord/selector.js';
9
13
  export interface SessionCatalogPort {
10
14
  listSessions(): Promise<{
11
15
  outcome: 'completed';
12
- sessions: ReadonlyArray<{
13
- sessionId: string;
14
- title: string | null;
15
- archived: boolean;
16
- }>;
16
+ sessions: ReadonlyArray<SessionResumeRow>;
17
17
  } | {
18
18
  outcome: 'failed';
19
19
  } | {
20
20
  outcome: 'unknown';
21
21
  }>;
22
22
  }
23
- export interface ResumeSelectorRequest {
24
- workspaceId: string;
25
- selectionId: string;
26
- query?: string | undefined;
27
- page?: number | undefined;
28
- }
29
- export type ResumeSelectorView = {
23
+ export type ResumeCandidatesPort = (query: string,
24
+ /**
25
+ * The bound Workspace's REGISTERED PATH, resolved by the router from the
26
+ * channel binding through the workspace catalog. Not a workspace id: the
27
+ * port must never re-resolve it through the catalog (an id/path swap here
28
+ * once answered `unavailable` in every channel — 16.45). `undefined`
29
+ * (unbound channel / catalog miss) is fail-closed.
30
+ */
31
+ workspacePath?: string,
32
+ /** The registry's archived set (16.49): archived sessions never resume. */
33
+ archivedSessionIds?: ReadonlySet<string>) => Promise<{
30
34
  outcome: 'ok';
31
- items: SelectorOption[];
32
- pageIndex: number;
33
- pageCount: number;
34
- hasPrev: boolean;
35
- hasNext: boolean;
36
- navValues: {
37
- prev?: string;
38
- next?: string;
39
- };
35
+ options: SelectorOption[];
40
36
  } | {
41
- outcome: 'failed';
42
- reason: 'session-catalog-unavailable' | 'session-catalog-unknown';
43
- };
44
- export declare function buildResumeSelector(port: SessionCatalogPort, request: ResumeSelectorRequest): Promise<ResumeSelectorView>;
37
+ outcome: 'unavailable';
38
+ }>;
39
+ /**
40
+ * Relative-time rendering for a candidate's label. Discord autocomplete
41
+ * choices carry no description field on the wire (name/value only), so the
42
+ * age rides the label — bilingual via the copy table (16.47).
43
+ */
44
+ export declare function relativeTime(copy: CopyTable, fromMs: number, nowMs: number): string;
45
+ /**
46
+ * Build the /session resume autocomplete candidates from one Host listing:
47
+ * blank sessions are hidden (nothing to resume), already-bound sessions are
48
+ * excluded (their thread is the live surface), the query filters over
49
+ * title/id case-insensitively, and rows sort newest-first. Uncapped here —
50
+ * the caller applies Discord's 25-choice ceiling. Same-titled candidates
51
+ * get a short-id suffix: choices have no description on the wire, so
52
+ * identical labels would be indistinguishable (16.47).
53
+ */
54
+ export declare function buildResumeCandidates(sessions: ReadonlyArray<SessionResumeRow>, options: {
55
+ /** Registered path of the bound Workspace; only its sessions are offered. */
56
+ workspacePath: string | undefined;
57
+ boundSessionIds?: ReadonlySet<string>;
58
+ /** Registry's archived set: archived sessions never resume (16.49). */
59
+ archivedSessionIds?: ReadonlySet<string>;
60
+ query?: string | undefined;
61
+ nowMs?: number;
62
+ copy: CopyTable;
63
+ }): SelectorOption[];
64
+ /** Discard candidates: prefix matches first, then substring, capped. */
65
+ export declare function filterResumeCandidates(candidates: ReadonlyArray<SelectorOption>, query: string, limit?: number): SelectorOption[];
66
+ /**
67
+ * The composition-root wiring for the router's `resumeCandidates` dep
68
+ * (16.44): one Host listing per call, filtered by the handed-in workspace
69
+ * path against the live thread-ownership set. Listing failure is logged at
70
+ * the face (`…failed`/`…timeout`) and degrades to `unavailable` — Discord
71
+ * autocomplete then answers empty choices, never an unanswered interaction.
72
+ */
73
+ export declare function createResumeCandidatesPort(deps: {
74
+ listSessions: SessionCatalogPort['listSessions'];
75
+ /** Sessions this adapter already owns a thread for, re-read per call. */
76
+ boundSessionIds: () => ReadonlySet<string>;
77
+ copy: CopyTable;
78
+ }): ResumeCandidatesPort;
@@ -1,40 +1,113 @@
1
1
  /**
2
- * `/session resume` selector (design.md §13, task 9.1). Metadata only: the
3
- * current workspace's sessions, filtered by id/title substring — never
4
- * full-text content search (disabled by default in the Web profile).
5
- * Untitled sessions fall back to their id; archived sessions carry an
6
- * explicit mark so the user can tell before adopting.
2
+ * `/session resume` candidate building (design.md §13, tasks 9.1 + 16.44).
3
+ * The Host's `sessions.list` returns rich rows (updatedAt desc, running,
4
+ * blank, cwd, title via projection values). Candidates exclude blank
5
+ * sessions and sessions this adapter already owns a thread for, filter by
6
+ * title/id substring, and sort newest-first — Discord autocomplete caps at
7
+ * 25 choices, the caller slices.
7
8
  */
8
- import { filterAutocomplete, paginateSelector } from '../discord/selector.js';
9
- import { safeTitle } from '../policy/disclosure.js';
10
- export async function buildResumeSelector(port, request) {
11
- const catalog = await port.listSessions();
12
- if (catalog.outcome === 'failed') {
13
- return { outcome: 'failed', reason: 'session-catalog-unavailable' };
14
- }
15
- if (catalog.outcome === 'unknown') {
16
- return { outcome: 'failed', reason: 'session-catalog-unknown' };
17
- }
18
- const selectable = catalog.sessions.map(session => ({
19
- label: session.archived
20
- ? `[archived] ${session.title === null ? session.sessionId : safeTitle(session.title)}`
21
- : session.title === null
22
- ? session.sessionId
23
- : safeTitle(session.title),
24
- value: `sess:${session.sessionId.replace('sess-', '')}`,
9
+ import { filterAutocomplete } from '../discord/selector.js';
10
+ /**
11
+ * Relative-time rendering for a candidate's label. Discord autocomplete
12
+ * choices carry no description field on the wire (name/value only), so the
13
+ * age rides the label — bilingual via the copy table (16.47).
14
+ */
15
+ export function relativeTime(copy, fromMs, nowMs) {
16
+ const delta = Math.max(0, nowMs - fromMs);
17
+ const minutes = Math.floor(delta / 60_000);
18
+ if (minutes < 1)
19
+ return copy.sessionCandidateJustNow;
20
+ if (minutes < 60)
21
+ return copy.sessionCandidateMinutesAgo(minutes);
22
+ const hours = Math.floor(minutes / 60);
23
+ if (hours < 24)
24
+ return copy.sessionCandidateHoursAgo(hours);
25
+ const days = Math.floor(hours / 24);
26
+ return copy.sessionCandidateDaysAgo(days);
27
+ }
28
+ /** Short display form of a session id (first 8 chars). */
29
+ function shortId(sessionId) {
30
+ return sessionId.slice(0, 8);
31
+ }
32
+ /** Discord rejects the whole autocomplete answer when a choice name exceeds 100. */
33
+ const DISCORD_CHOICE_NAME_MAX = 100;
34
+ function candidateLabel(copy, session, nowMs) {
35
+ const parts = [session.title ?? shortId(session.sessionId), relativeTime(copy, session.updatedAt, nowMs)];
36
+ if (session.running)
37
+ parts.push(copy.sessionCandidateRunning);
38
+ const label = parts.join(' · ');
39
+ return label.length <= DISCORD_CHOICE_NAME_MAX ? label : label.slice(0, DISCORD_CHOICE_NAME_MAX);
40
+ }
41
+ /**
42
+ * Build the /session resume autocomplete candidates from one Host listing:
43
+ * blank sessions are hidden (nothing to resume), already-bound sessions are
44
+ * excluded (their thread is the live surface), the query filters over
45
+ * title/id case-insensitively, and rows sort newest-first. Uncapped here —
46
+ * the caller applies Discord's 25-choice ceiling. Same-titled candidates
47
+ * get a short-id suffix: choices have no description on the wire, so
48
+ * identical labels would be indistinguishable (16.47).
49
+ */
50
+ export function buildResumeCandidates(sessions, options) {
51
+ const query = (options.query ?? '').trim().toLowerCase();
52
+ const nowMs = options.nowMs ?? Date.now();
53
+ const bound = options.boundSessionIds ?? new Set();
54
+ const archived = options.archivedSessionIds ?? new Set();
55
+ const copy = options.copy;
56
+ const rows = sessions
57
+ .filter(session => !session.blank && !bound.has(session.sessionId))
58
+ // Subagent sessions are never resumable as top-level threads (the spec's
59
+ // "Subagent Session selected" refusal) — offering them only produces
60
+ // selections that must fail (16.48).
61
+ .filter(session => session.origin !== 'subagent')
62
+ // Archived sessions adopt fine but never run a turn — the user waits in
63
+ // a dead thread. `workspace.list` carries the registry's archived set;
64
+ // sessions.list rows have no archived marker (16.49).
65
+ .filter(session => !archived.has(session.sessionId))
66
+ // Workspace scoping (16.44): a session belongs to the channel's Workspace
67
+ // only when its recorded cwd is that Workspace's registered path.
68
+ // Sessions with no recorded cwd cannot be attributed and are never
69
+ // offered.
70
+ .filter(session => options.workspacePath === undefined || session.cwd === options.workspacePath)
71
+ .sort((a, b) => b.updatedAt - a.updatedAt);
72
+ const options_ = rows.map(session => ({
73
+ label: candidateLabel(copy, session, nowMs),
74
+ value: session.sessionId,
25
75
  }));
26
- const query = request.query ?? '';
27
- const matches = query.trim() === ''
28
- ? selectable
29
- : filterAutocomplete(selectable, query.trim(), Number.POSITIVE_INFINITY);
30
- const page = paginateSelector(matches, request.page ?? 0, `${request.selectionId}:page`);
31
- return {
32
- outcome: 'ok',
33
- items: page.items,
34
- pageIndex: page.pageIndex,
35
- pageCount: page.pageCount,
36
- hasPrev: page.hasPrev,
37
- hasNext: page.hasNext,
38
- navValues: page.navValues,
76
+ const counts = new Map();
77
+ for (const option of options_)
78
+ counts.set(option.label, (counts.get(option.label) ?? 0) + 1);
79
+ const disambiguated = options_.map(option => (counts.get(option.label) ?? 0) > 1
80
+ ? { label: `${option.label} · ${shortId(option.value)}`.slice(0, DISCORD_CHOICE_NAME_MAX), value: option.value }
81
+ : option);
82
+ if (query === '')
83
+ return disambiguated;
84
+ return disambiguated.filter(option => option.label.toLowerCase().includes(query) || option.value.toLowerCase().includes(query));
85
+ }
86
+ /** Discard candidates: prefix matches first, then substring, capped. */
87
+ export function filterResumeCandidates(candidates, query, limit = 25) {
88
+ return filterAutocomplete(candidates, query, limit);
89
+ }
90
+ /**
91
+ * The composition-root wiring for the router's `resumeCandidates` dep
92
+ * (16.44): one Host listing per call, filtered by the handed-in workspace
93
+ * path against the live thread-ownership set. Listing failure is logged at
94
+ * the face (`…failed`/`…timeout`) and degrades to `unavailable` — Discord
95
+ * autocomplete then answers empty choices, never an unanswered interaction.
96
+ */
97
+ export function createResumeCandidatesPort(deps) {
98
+ return async (query, workspacePath, archivedSessionIds) => {
99
+ if (workspacePath === undefined)
100
+ return { outcome: 'unavailable' };
101
+ const summaries = await deps.listSessions();
102
+ if (summaries.outcome !== 'completed')
103
+ return { outcome: 'unavailable' };
104
+ const candidates = buildResumeCandidates(summaries.sessions, {
105
+ workspacePath,
106
+ boundSessionIds: deps.boundSessionIds(),
107
+ ...(archivedSessionIds === undefined ? {} : { archivedSessionIds }),
108
+ query,
109
+ copy: deps.copy,
110
+ });
111
+ return { outcome: 'ok', options: filterResumeCandidates(candidates, query, 25) };
39
112
  };
40
113
  }
@@ -40,6 +40,20 @@ export interface NormalizedMessage {
40
40
  mentionedBot: boolean;
41
41
  /** The snowflake of the message this one replies to, when present and valid. */
42
42
  repliedToId: DiscordSnowflake | undefined;
43
+ /**
44
+ * DECLARED image-attachment metadata (16.50): supported types only,
45
+ * malformed entries skipped, capped at Discord's per-message limit. No
46
+ * bytes cross this boundary — download happens later, behind the
47
+ * allowlisted fetch boundary.
48
+ */
49
+ images: DiscordImageAttachment[];
50
+ }
51
+ /** One supported image attachment, as declared by the wire. */
52
+ export interface DiscordImageAttachment {
53
+ url: string;
54
+ filename: string;
55
+ declaredSize: number;
56
+ contentType: string;
43
57
  }
44
58
  /** A validated guild interaction with its actor identity. */
45
59
  export interface NormalizedInteraction {
@@ -40,6 +40,46 @@ function extractRoleIds(payload) {
40
40
  function asSnowflake(value) {
41
41
  return isDiscordSnowflake(value) ? value : undefined;
42
42
  }
43
+ /** Image content types the adapter carries (design.md §12). */
44
+ const SUPPORTED_IMAGE_TYPES = new Set([
45
+ 'image/jpeg',
46
+ 'image/png',
47
+ 'image/webp',
48
+ 'image/gif',
49
+ ]);
50
+ /** Discord's per-message attachment cap; a defensive bound on untrusted input. */
51
+ const MAX_MESSAGE_IMAGES = 10;
52
+ /**
53
+ * Narrow untrusted `attachments` onto declared image metadata. Entries that
54
+ * are not records, lack any required field, or declare an unsupported media
55
+ * type are skipped — a malformed attachment never rejects the message.
56
+ */
57
+ function extractImages(payload) {
58
+ const raw = payload['attachments'];
59
+ if (!Array.isArray(raw))
60
+ return [];
61
+ const images = [];
62
+ for (const entry of raw) {
63
+ if (images.length >= MAX_MESSAGE_IMAGES)
64
+ break;
65
+ if (!isRecord(entry))
66
+ continue;
67
+ const url = entry['url'];
68
+ const filename = entry['filename'];
69
+ const size = entry['size'];
70
+ const contentType = entry['content_type'];
71
+ if (typeof url !== 'string' || url === '')
72
+ continue;
73
+ if (typeof filename !== 'string' || filename === '')
74
+ continue;
75
+ if (typeof size !== 'number' || !Number.isFinite(size) || size < 0)
76
+ continue;
77
+ if (typeof contentType !== 'string' || !SUPPORTED_IMAGE_TYPES.has(contentType))
78
+ continue;
79
+ images.push({ url, filename, declaredSize: size, contentType });
80
+ }
81
+ return images;
82
+ }
43
83
  function parseMessage(payload, selfUserId) {
44
84
  // Webhook-authored messages are app-driven surfaces, not member input:
45
85
  // their author object need not carry `bot: true`, and anyone with
@@ -93,6 +133,7 @@ function parseMessage(payload, selfUserId) {
93
133
  content: stripped.text,
94
134
  mentionedBot: stripped.mentioned,
95
135
  repliedToId,
136
+ images: extractImages(payload),
96
137
  },
97
138
  };
98
139
  }