@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.
- package/LICENSE +21 -0
- package/README.md +68 -0
- package/cordis.patch.yml +14 -0
- package/lib/client/DiscordSettingsCard.d.ts +6 -0
- package/lib/client/PluginCard.d.ts +43 -0
- package/lib/client/card-controller.d.ts +22 -0
- package/lib/client/card-form.d.ts +111 -0
- package/lib/client/fields.d.ts +50 -0
- package/lib/client/index.d.ts +12 -0
- package/lib/client/nav-icon.d.ts +21 -0
- package/lib/client/settings-model.d.ts +49 -0
- package/lib/client/slot-contract.d.ts +57 -0
- package/lib/client/snapshot-store.d.ts +9 -0
- package/lib/client.js +1622 -0
- package/lib/compose.d.ts +158 -0
- package/lib/compose.js +227 -0
- package/lib/credential.d.ts +20 -0
- package/lib/credential.js +24 -0
- package/lib/discord/commands.d.ts +45 -0
- package/lib/discord/commands.js +87 -0
- package/lib/discord/components.d.ts +29 -0
- package/lib/discord/components.js +57 -0
- package/lib/discord/delivery.d.ts +21 -0
- package/lib/discord/delivery.js +31 -0
- package/lib/discord/interaction-lifecycle.d.ts +42 -0
- package/lib/discord/interaction-lifecycle.js +78 -0
- package/lib/discord/rest.d.ts +74 -0
- package/lib/discord/rest.js +149 -0
- package/lib/discord/selector.d.ts +39 -0
- package/lib/discord/selector.js +70 -0
- package/lib/discord/thread-port.d.ts +37 -0
- package/lib/discord/thread-port.js +58 -0
- package/lib/dsh/api-proxy-face.d.ts +253 -0
- package/lib/dsh/api-proxy-face.js +370 -0
- package/lib/features/adapter-status.d.ts +111 -0
- package/lib/features/adapter-status.js +103 -0
- package/lib/features/approval-expiry.d.ts +27 -0
- package/lib/features/approval-expiry.js +36 -0
- package/lib/features/approval-routing.d.ts +50 -0
- package/lib/features/approval-routing.js +57 -0
- package/lib/features/approval-store.d.ts +86 -0
- package/lib/features/approval-store.js +119 -0
- package/lib/features/approval-view.d.ts +47 -0
- package/lib/features/approval-view.js +48 -0
- package/lib/features/ask-wiring.d.ts +74 -0
- package/lib/features/ask-wiring.js +151 -0
- package/lib/features/host-status.d.ts +41 -0
- package/lib/features/host-status.js +24 -0
- package/lib/features/image-collection.d.ts +56 -0
- package/lib/features/image-collection.js +79 -0
- package/lib/features/image-download.d.ts +48 -0
- package/lib/features/image-download.js +77 -0
- package/lib/features/image-submission.d.ts +67 -0
- package/lib/features/image-submission.js +57 -0
- package/lib/features/interaction-router.d.ts +84 -0
- package/lib/features/interaction-router.js +655 -0
- package/lib/features/model-control.d.ts +79 -0
- package/lib/features/model-control.js +51 -0
- package/lib/features/preset-control.d.ts +84 -0
- package/lib/features/preset-control.js +43 -0
- package/lib/features/project-bind.d.ts +69 -0
- package/lib/features/project-bind.js +57 -0
- package/lib/features/project-info.d.ts +25 -0
- package/lib/features/project-info.js +22 -0
- package/lib/features/project-list.d.ts +79 -0
- package/lib/features/project-list.js +93 -0
- package/lib/features/prompt-submission.d.ts +53 -0
- package/lib/features/prompt-submission.js +47 -0
- package/lib/features/question-expiry.d.ts +53 -0
- package/lib/features/question-expiry.js +56 -0
- package/lib/features/question-routing.d.ts +108 -0
- package/lib/features/question-routing.js +162 -0
- package/lib/features/question-store.d.ts +124 -0
- package/lib/features/question-store.js +186 -0
- package/lib/features/question-view.d.ts +58 -0
- package/lib/features/question-view.js +81 -0
- package/lib/features/queue-control.d.ts +63 -0
- package/lib/features/queue-control.js +31 -0
- package/lib/features/reconcile-bindings.d.ts +75 -0
- package/lib/features/reconcile-bindings.js +54 -0
- package/lib/features/reconcile-deliveries.d.ts +29 -0
- package/lib/features/reconcile-deliveries.js +28 -0
- package/lib/features/reconcile-events.d.ts +49 -0
- package/lib/features/reconcile-events.js +50 -0
- package/lib/features/reconcile-intents.d.ts +32 -0
- package/lib/features/reconcile-intents.js +32 -0
- package/lib/features/reconcile-interactions.d.ts +59 -0
- package/lib/features/reconcile-interactions.js +54 -0
- package/lib/features/session-adopt.d.ts +89 -0
- package/lib/features/session-adopt.js +77 -0
- package/lib/features/session-creation.d.ts +49 -0
- package/lib/features/session-creation.js +43 -0
- package/lib/features/session-mainline.d.ts +75 -0
- package/lib/features/session-mainline.js +96 -0
- package/lib/features/session-resume.d.ts +44 -0
- package/lib/features/session-resume.js +40 -0
- package/lib/features/skill-run.d.ts +48 -0
- package/lib/features/skill-run.js +25 -0
- package/lib/features/steer-control.d.ts +37 -0
- package/lib/features/steer-control.js +28 -0
- package/lib/features/stop-control.d.ts +38 -0
- package/lib/features/stop-control.js +31 -0
- package/lib/features/task-admission.d.ts +29 -0
- package/lib/features/task-admission.js +21 -0
- package/lib/features/thread-continuation.d.ts +36 -0
- package/lib/features/thread-continuation.js +18 -0
- package/lib/features/thread-creation.d.ts +81 -0
- package/lib/features/thread-creation.js +81 -0
- package/lib/features/thread-routing.d.ts +49 -0
- package/lib/features/thread-routing.js +34 -0
- package/lib/features/turn-ownership.d.ts +39 -0
- package/lib/features/turn-ownership.js +40 -0
- package/lib/features/unbound-mention.d.ts +49 -0
- package/lib/features/unbound-mention.js +31 -0
- package/lib/features/workspace-channel.d.ts +41 -0
- package/lib/features/workspace-channel.js +47 -0
- package/lib/gateway/gateway.d.ts +50 -0
- package/lib/gateway/gateway.js +229 -0
- package/lib/gateway/inbound.d.ts +85 -0
- package/lib/gateway/inbound.js +186 -0
- package/lib/gateway/ingress.d.ts +15 -0
- package/lib/gateway/ingress.js +22 -0
- package/lib/i18n.d.ts +92 -0
- package/lib/i18n.js +180 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.js +869 -0
- package/lib/lifecycle.d.ts +44 -0
- package/lib/lifecycle.js +84 -0
- package/lib/policy/authorization.d.ts +41 -0
- package/lib/policy/authorization.js +64 -0
- package/lib/policy/disclosure.d.ts +93 -0
- package/lib/policy/disclosure.js +153 -0
- package/lib/policy/guard.d.ts +31 -0
- package/lib/policy/guard.js +46 -0
- package/lib/policy/suppress.d.ts +13 -0
- package/lib/policy/suppress.js +20 -0
- package/lib/settings-namespace.d.ts +7 -0
- package/lib/settings-namespace.js +6 -0
- package/lib/settings.d.ts +37 -0
- package/lib/settings.js +102 -0
- package/lib/startup.d.ts +16 -0
- package/lib/startup.js +60 -0
- package/lib/state/bindings.d.ts +46 -0
- package/lib/state/bindings.js +55 -0
- package/lib/state/channel-bindings.d.ts +37 -0
- package/lib/state/channel-bindings.js +43 -0
- package/lib/state/domain.d.ts +82 -0
- package/lib/state/domain.js +86 -0
- package/lib/state/effect-machine.d.ts +37 -0
- package/lib/state/effect-machine.js +52 -0
- package/lib/state/fail-closed.d.ts +44 -0
- package/lib/state/fail-closed.js +57 -0
- package/lib/state/intents.d.ts +51 -0
- package/lib/state/intents.js +73 -0
- package/lib/state/records.d.ts +37 -0
- package/lib/state/records.js +30 -0
- package/lib/state/retention.d.ts +74 -0
- package/lib/state/retention.js +68 -0
- package/lib/state/session-owners.d.ts +50 -0
- package/lib/state/session-owners.js +50 -0
- package/lib/stream/finalizer.d.ts +49 -0
- package/lib/stream/finalizer.js +67 -0
- package/lib/stream/icons.d.ts +15 -0
- package/lib/stream/icons.js +36 -0
- package/lib/stream/live.d.ts +140 -0
- package/lib/stream/live.js +515 -0
- package/lib/stream/markdown.d.ts +24 -0
- package/lib/stream/markdown.js +95 -0
- package/lib/stream/outbound.d.ts +27 -0
- package/lib/stream/outbound.js +25 -0
- package/lib/stream/render-fence.d.ts +27 -0
- package/lib/stream/render-fence.js +37 -0
- package/lib/stream/render-model.d.ts +48 -0
- package/lib/stream/render-model.js +68 -0
- package/lib/stream/splitter.d.ts +8 -0
- package/lib/stream/splitter.js +58 -0
- package/lib/stream/tool-view.d.ts +40 -0
- package/lib/stream/tool-view.js +39 -0
- package/lib/stream/typing.d.ts +22 -0
- package/lib/stream/typing.js +58 -0
- package/lib/stream/update-scheduler.d.ts +20 -0
- package/lib/stream/update-scheduler.js +65 -0
- package/package.json +86 -0
|
@@ -0,0 +1,44 @@
|
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
import { type SelectorOption } from '../discord/selector.js';
|
|
9
|
+
export interface SessionCatalogPort {
|
|
10
|
+
listSessions(): Promise<{
|
|
11
|
+
outcome: 'completed';
|
|
12
|
+
sessions: ReadonlyArray<{
|
|
13
|
+
sessionId: string;
|
|
14
|
+
title: string | null;
|
|
15
|
+
archived: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
} | {
|
|
18
|
+
outcome: 'failed';
|
|
19
|
+
} | {
|
|
20
|
+
outcome: 'unknown';
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
export interface ResumeSelectorRequest {
|
|
24
|
+
workspaceId: string;
|
|
25
|
+
selectionId: string;
|
|
26
|
+
query?: string | undefined;
|
|
27
|
+
page?: number | undefined;
|
|
28
|
+
}
|
|
29
|
+
export type ResumeSelectorView = {
|
|
30
|
+
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
|
+
};
|
|
40
|
+
} | {
|
|
41
|
+
outcome: 'failed';
|
|
42
|
+
reason: 'session-catalog-unavailable' | 'session-catalog-unknown';
|
|
43
|
+
};
|
|
44
|
+
export declare function buildResumeSelector(port: SessionCatalogPort, request: ResumeSelectorRequest): Promise<ResumeSelectorView>;
|
|
@@ -0,0 +1,40 @@
|
|
|
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.
|
|
7
|
+
*/
|
|
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-', '')}`,
|
|
25
|
+
}));
|
|
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,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/skill run <skill> [input]` (design.md §13, task 10.4). Skill availability
|
|
3
|
+
* comes from the DSH catalog; operator-only skills refuse ordinary members.
|
|
4
|
+
* A passing check submits the CANONICAL slash invocation (`/<skill> <args>`)
|
|
5
|
+
* through the queued session prompt — skills ride the same at-most-once
|
|
6
|
+
* queue as ordinary thread messages, never a side channel.
|
|
7
|
+
*/
|
|
8
|
+
import type { AccessDecision } from '../policy/authorization.js';
|
|
9
|
+
export interface DshSkillPort {
|
|
10
|
+
listSkills(): Promise<{
|
|
11
|
+
outcome: 'completed';
|
|
12
|
+
skills: ReadonlyArray<{
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
operatorOnly: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
} | {
|
|
18
|
+
outcome: 'failed';
|
|
19
|
+
} | {
|
|
20
|
+
outcome: 'unknown';
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
export interface SkillPromptSubmit {
|
|
24
|
+
submit(request: {
|
|
25
|
+
sessionId: string;
|
|
26
|
+
requestId: string;
|
|
27
|
+
prompt: string;
|
|
28
|
+
}): Promise<{
|
|
29
|
+
outcome: 'queued';
|
|
30
|
+
position: number;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
export type SkillRunResult = {
|
|
34
|
+
outcome: 'queued';
|
|
35
|
+
} | {
|
|
36
|
+
outcome: 'failed';
|
|
37
|
+
} | {
|
|
38
|
+
outcome: 'refused';
|
|
39
|
+
reason: 'skill-not-found' | 'operator-only-skill' | 'empty-skill';
|
|
40
|
+
};
|
|
41
|
+
export declare function runSkill(port: DshSkillPort, submit: SkillPromptSubmit, request: {
|
|
42
|
+
decision: AccessDecision;
|
|
43
|
+
sessionId: string;
|
|
44
|
+
requestId: string;
|
|
45
|
+
/** Canonical skill id the user typed after /skill run. */
|
|
46
|
+
skillInput: string;
|
|
47
|
+
args: readonly string[];
|
|
48
|
+
}): Promise<SkillRunResult>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/skill run <skill> [input]` (design.md §13, task 10.4). Skill availability
|
|
3
|
+
* comes from the DSH catalog; operator-only skills refuse ordinary members.
|
|
4
|
+
* A passing check submits the CANONICAL slash invocation (`/<skill> <args>`)
|
|
5
|
+
* through the queued session prompt — skills ride the same at-most-once
|
|
6
|
+
* queue as ordinary thread messages, never a side channel.
|
|
7
|
+
*/
|
|
8
|
+
export async function runSkill(port, submit, request) {
|
|
9
|
+
const skillId = request.skillInput.trim();
|
|
10
|
+
if (skillId === '')
|
|
11
|
+
return { outcome: 'refused', reason: 'empty-skill' };
|
|
12
|
+
const catalog = await port.listSkills();
|
|
13
|
+
if (catalog.outcome !== 'completed')
|
|
14
|
+
return { outcome: 'failed' };
|
|
15
|
+
const skill = catalog.skills.find(candidate => candidate.id === skillId);
|
|
16
|
+
if (skill === undefined)
|
|
17
|
+
return { outcome: 'refused', reason: 'skill-not-found' };
|
|
18
|
+
const isOperator = request.decision.allowed && request.decision.level === 'host-operator';
|
|
19
|
+
if (skill.operatorOnly && !isOperator) {
|
|
20
|
+
return { outcome: 'refused', reason: 'operator-only-skill' };
|
|
21
|
+
}
|
|
22
|
+
const canonical = `/${skillId}${request.args.length === 0 ? '' : ` ${request.args.join(' ')}`}`;
|
|
23
|
+
await submit.submit({ sessionId: request.sessionId, requestId: request.requestId, prompt: canonical });
|
|
24
|
+
return { outcome: 'queued' };
|
|
25
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/steer <prompt>` (design.md §5, task 9.5). Steering submits
|
|
3
|
+
* `mode: 'steer'` against the calling thread's OWNED active turn — the
|
|
4
|
+
* ownership comes from the turn tracker's request-ID registration, never
|
|
5
|
+
* from a session's running status. Late steers and foreign threads are
|
|
6
|
+
* refused locally without a DSH call.
|
|
7
|
+
*/
|
|
8
|
+
import type { TurnTracker } from './turn-ownership.js';
|
|
9
|
+
export interface DshSteerPort {
|
|
10
|
+
steer(request: {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
requestId: string;
|
|
13
|
+
prompt: string;
|
|
14
|
+
}): Promise<{
|
|
15
|
+
outcome: 'accepted';
|
|
16
|
+
} | {
|
|
17
|
+
outcome: 'rejected';
|
|
18
|
+
reason: string;
|
|
19
|
+
} | {
|
|
20
|
+
outcome: 'unknown';
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
export type SteerResult = {
|
|
24
|
+
outcome: 'accepted';
|
|
25
|
+
} | {
|
|
26
|
+
outcome: 'rejected';
|
|
27
|
+
} | {
|
|
28
|
+
outcome: 'unknown';
|
|
29
|
+
} | {
|
|
30
|
+
outcome: 'refused';
|
|
31
|
+
reason: 'no-active-turn' | 'not-authorized';
|
|
32
|
+
};
|
|
33
|
+
export declare function planSteer(port: DshSteerPort, tracker: TurnTracker, request: {
|
|
34
|
+
sessionId: string;
|
|
35
|
+
threadId: string;
|
|
36
|
+
prompt: string;
|
|
37
|
+
}): Promise<SteerResult>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/steer <prompt>` (design.md §5, task 9.5). Steering submits
|
|
3
|
+
* `mode: 'steer'` against the calling thread's OWNED active turn — the
|
|
4
|
+
* ownership comes from the turn tracker's request-ID registration, never
|
|
5
|
+
* from a session's running status. Late steers and foreign threads are
|
|
6
|
+
* refused locally without a DSH call.
|
|
7
|
+
*/
|
|
8
|
+
export async function planSteer(port, tracker, request) {
|
|
9
|
+
const control = tracker.authorizeControl({ sessionId: request.sessionId, threadId: request.threadId });
|
|
10
|
+
if (!control.allowed) {
|
|
11
|
+
// Map the tracker's ownership facts onto caller-facing refusals: a
|
|
12
|
+
// foreign-owned turn is simply not authorized for this thread.
|
|
13
|
+
return {
|
|
14
|
+
outcome: 'refused',
|
|
15
|
+
reason: control.reason === 'no-active-turn' ? 'no-active-turn' : 'not-authorized',
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const steered = await port.steer({
|
|
19
|
+
sessionId: request.sessionId,
|
|
20
|
+
requestId: control.requestId,
|
|
21
|
+
prompt: request.prompt,
|
|
22
|
+
});
|
|
23
|
+
if (steered.outcome === 'accepted')
|
|
24
|
+
return { outcome: 'accepted' };
|
|
25
|
+
if (steered.outcome === 'unknown')
|
|
26
|
+
return { outcome: 'unknown' };
|
|
27
|
+
return { outcome: 'rejected' };
|
|
28
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/stop` (design.md §5, task 9.6). Stopping cancels only the calling
|
|
3
|
+
* thread's OWNED active turn — the same request-ID ownership as steering.
|
|
4
|
+
* The cancel request carries no purge semantics: DSH preserves the session's
|
|
5
|
+
* pending inbox, so queued prompts survive a stop. After any cancel attempt
|
|
6
|
+
* the turn is terminal for the adapter (duplicates and late stops refuse
|
|
7
|
+
* locally), and DSH outcomes surface as plain values.
|
|
8
|
+
*/
|
|
9
|
+
import type { TurnTracker } from './turn-ownership.js';
|
|
10
|
+
export interface DshCancelPort {
|
|
11
|
+
cancel(request: {
|
|
12
|
+
sessionId: string;
|
|
13
|
+
requestId: string;
|
|
14
|
+
}): Promise<{
|
|
15
|
+
outcome: 'accepted';
|
|
16
|
+
pendingPreserved: boolean;
|
|
17
|
+
} | {
|
|
18
|
+
outcome: 'rejected';
|
|
19
|
+
reason: string;
|
|
20
|
+
} | {
|
|
21
|
+
outcome: 'unknown';
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
export type StopResult = {
|
|
25
|
+
outcome: 'cancelled';
|
|
26
|
+
pendingPreserved: boolean;
|
|
27
|
+
} | {
|
|
28
|
+
outcome: 'rejected';
|
|
29
|
+
} | {
|
|
30
|
+
outcome: 'unknown';
|
|
31
|
+
} | {
|
|
32
|
+
outcome: 'refused';
|
|
33
|
+
reason: 'no-active-turn' | 'not-authorized';
|
|
34
|
+
};
|
|
35
|
+
export declare function planStop(port: DshCancelPort, tracker: TurnTracker, request: {
|
|
36
|
+
sessionId: string;
|
|
37
|
+
threadId: string;
|
|
38
|
+
}): Promise<StopResult>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/stop` (design.md §5, task 9.6). Stopping cancels only the calling
|
|
3
|
+
* thread's OWNED active turn — the same request-ID ownership as steering.
|
|
4
|
+
* The cancel request carries no purge semantics: DSH preserves the session's
|
|
5
|
+
* pending inbox, so queued prompts survive a stop. After any cancel attempt
|
|
6
|
+
* the turn is terminal for the adapter (duplicates and late stops refuse
|
|
7
|
+
* locally), and DSH outcomes surface as plain values.
|
|
8
|
+
*/
|
|
9
|
+
export async function planStop(port, tracker, request) {
|
|
10
|
+
const control = tracker.authorizeControl({ sessionId: request.sessionId, threadId: request.threadId });
|
|
11
|
+
if (!control.allowed) {
|
|
12
|
+
return {
|
|
13
|
+
outcome: 'refused',
|
|
14
|
+
reason: control.reason === 'no-active-turn' ? 'no-active-turn' : 'not-authorized',
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const cancelled = await port.cancel({
|
|
18
|
+
sessionId: request.sessionId,
|
|
19
|
+
requestId: control.requestId,
|
|
20
|
+
});
|
|
21
|
+
// The turn is terminal for this thread regardless of the wire outcome:
|
|
22
|
+
// after an accepted, rejected, or unknown cancel there is no controllable
|
|
23
|
+
// active turn left, so a duplicate or late stop refuses locally.
|
|
24
|
+
tracker.complete(control.requestId);
|
|
25
|
+
if (cancelled.outcome === 'accepted') {
|
|
26
|
+
return { outcome: 'cancelled', pendingPreserved: cancelled.pendingPreserved };
|
|
27
|
+
}
|
|
28
|
+
if (cancelled.outcome === 'unknown')
|
|
29
|
+
return { outcome: 'unknown' };
|
|
30
|
+
return { outcome: 'rejected' };
|
|
31
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* New-task admission (design.md §4). In a bound project channel, a bot
|
|
3
|
+
* mention carrying non-empty text or a supported image is the canonical
|
|
4
|
+
* new-task interface: it admits one task against the channel's current
|
|
5
|
+
* workspace. Empty mentions answer ephemerally, silent messages are ignored,
|
|
6
|
+
* and unbound channels defer to the bind-affordance flow. Authorization is
|
|
7
|
+
* an input fact — the upstream guard already refused everyone else.
|
|
8
|
+
*/
|
|
9
|
+
import type { AccessDecision } from '../policy/authorization.js';
|
|
10
|
+
export type NewTaskAdmission = {
|
|
11
|
+
outcome: 'admit-new-task';
|
|
12
|
+
workspaceId: string;
|
|
13
|
+
prompt: string;
|
|
14
|
+
} | {
|
|
15
|
+
outcome: 'empty-mention';
|
|
16
|
+
response: 'ephemeral';
|
|
17
|
+
} | {
|
|
18
|
+
outcome: 'defer-unbound';
|
|
19
|
+
} | {
|
|
20
|
+
outcome: 'ignore';
|
|
21
|
+
};
|
|
22
|
+
export declare function admitNewTask(input: {
|
|
23
|
+
decision: AccessDecision;
|
|
24
|
+
isBound: boolean;
|
|
25
|
+
channelWorkspaceId: string | undefined;
|
|
26
|
+
mentionedBot: boolean;
|
|
27
|
+
content: string;
|
|
28
|
+
hasSupportedImage: boolean;
|
|
29
|
+
}): NewTaskAdmission;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* New-task admission (design.md §4). In a bound project channel, a bot
|
|
3
|
+
* mention carrying non-empty text or a supported image is the canonical
|
|
4
|
+
* new-task interface: it admits one task against the channel's current
|
|
5
|
+
* workspace. Empty mentions answer ephemerally, silent messages are ignored,
|
|
6
|
+
* and unbound channels defer to the bind-affordance flow. Authorization is
|
|
7
|
+
* an input fact — the upstream guard already refused everyone else.
|
|
8
|
+
*/
|
|
9
|
+
export function admitNewTask(input) {
|
|
10
|
+
if (!input.decision.allowed)
|
|
11
|
+
return { outcome: 'ignore' };
|
|
12
|
+
if (!input.isBound || input.channelWorkspaceId === undefined)
|
|
13
|
+
return { outcome: 'defer-unbound' };
|
|
14
|
+
if (!input.mentionedBot)
|
|
15
|
+
return { outcome: 'ignore' };
|
|
16
|
+
const prompt = input.content.trim();
|
|
17
|
+
if (prompt === '' && !input.hasSupportedImage) {
|
|
18
|
+
return { outcome: 'empty-mention', response: 'ephemeral' };
|
|
19
|
+
}
|
|
20
|
+
return { outcome: 'admit-new-task', workspaceId: input.channelWorkspaceId, prompt };
|
|
21
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ordinary thread continuation (design.md §5, task 8.5). Every normal message
|
|
3
|
+
* in an adapter-owned thread submits through `session.prompt` with
|
|
4
|
+
* `mode: 'queue'` — the queue is what makes idle and busy sessions equally
|
|
5
|
+
* safe, and ordinary messages never improvise steer or stop semantics
|
|
6
|
+
* (those are the explicit `/steer` and `/stop` commands).
|
|
7
|
+
*/
|
|
8
|
+
export interface DshPromptPort {
|
|
9
|
+
submit(request: {
|
|
10
|
+
requestId: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
prompt: string;
|
|
13
|
+
mode: 'queue';
|
|
14
|
+
}): Promise<{
|
|
15
|
+
outcome: 'queued';
|
|
16
|
+
position: number;
|
|
17
|
+
} | {
|
|
18
|
+
outcome: 'rejected';
|
|
19
|
+
reason: string;
|
|
20
|
+
} | {
|
|
21
|
+
outcome: 'unknown';
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
export type ContinuationResult = {
|
|
25
|
+
outcome: 'queued';
|
|
26
|
+
position: number;
|
|
27
|
+
} | {
|
|
28
|
+
outcome: 'rejected';
|
|
29
|
+
} | {
|
|
30
|
+
outcome: 'unknown';
|
|
31
|
+
};
|
|
32
|
+
export declare function continueThread(prompts: DshPromptPort, request: {
|
|
33
|
+
sessionId: string;
|
|
34
|
+
messageId: string;
|
|
35
|
+
content: string;
|
|
36
|
+
}): Promise<ContinuationResult>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ordinary thread continuation (design.md §5, task 8.5). Every normal message
|
|
3
|
+
* in an adapter-owned thread submits through `session.prompt` with
|
|
4
|
+
* `mode: 'queue'` — the queue is what makes idle and busy sessions equally
|
|
5
|
+
* safe, and ordinary messages never improvise steer or stop semantics
|
|
6
|
+
* (those are the explicit `/steer` and `/stop` commands).
|
|
7
|
+
*/
|
|
8
|
+
export async function continueThread(prompts, request) {
|
|
9
|
+
const submitted = await prompts.submit({
|
|
10
|
+
requestId: request.messageId,
|
|
11
|
+
sessionId: request.sessionId,
|
|
12
|
+
prompt: request.content,
|
|
13
|
+
mode: 'queue',
|
|
14
|
+
});
|
|
15
|
+
if (submitted.outcome === 'queued')
|
|
16
|
+
return { outcome: 'queued', position: submitted.position };
|
|
17
|
+
return submitted.outcome === 'rejected' ? { outcome: 'rejected' } : { outcome: 'unknown' };
|
|
18
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread creation over the source-message intent (design.md §4, §10, anchored-thread
|
|
3
|
+
* thread model). The source Discord message id is the stable claim: the
|
|
4
|
+
* first creator creates a thread ANCHORED to that message (Discord moves it
|
|
5
|
+
* into the thread as its first post — the user's task text opens the
|
|
6
|
+
* thread) and records it on the intent; any redelivery of the same message
|
|
7
|
+
* recovers that thread (from the record, or — across a crash window where
|
|
8
|
+
* the intent exists but carries no thread id — from Discord's deterministic
|
|
9
|
+
* source-message lookup). The author is joined to the thread so it appears
|
|
10
|
+
* in their sidebar (live-tested: "add user to thread so it appears in their
|
|
11
|
+
* sidebar"). Different content under the same message id conflicts, and
|
|
12
|
+
* creation failures surface as values.
|
|
13
|
+
*/
|
|
14
|
+
import type { IntentStore } from '../state/intents.js';
|
|
15
|
+
/** The Discord thread surface the flow needs (REST in production, fakes here). */
|
|
16
|
+
export interface DiscordThreadPort {
|
|
17
|
+
createThread(request: {
|
|
18
|
+
parentChannelId: string;
|
|
19
|
+
name: string;
|
|
20
|
+
/** The source message anchors the thread (its durable first message). */
|
|
21
|
+
sourceMessageId: string;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
outcome: 'completed';
|
|
24
|
+
threadId: string;
|
|
25
|
+
} | {
|
|
26
|
+
outcome: 'failed';
|
|
27
|
+
} | {
|
|
28
|
+
outcome: 'unknown';
|
|
29
|
+
}>;
|
|
30
|
+
/** Deterministic recovery: find the thread created for this source message. */
|
|
31
|
+
findThreadBySource(request: {
|
|
32
|
+
guildId: string;
|
|
33
|
+
sourceMessageId: string;
|
|
34
|
+
}): Promise<{
|
|
35
|
+
outcome: 'found';
|
|
36
|
+
threadId: string;
|
|
37
|
+
} | {
|
|
38
|
+
outcome: 'not-found';
|
|
39
|
+
}>;
|
|
40
|
+
/**
|
|
41
|
+
* Join the task author to the thread: Discord sidebars list only threads
|
|
42
|
+
* the user has joined, so the anchored task thread stays invisible to its
|
|
43
|
+
* author until this runs. Best-effort by contract; never fails the task.
|
|
44
|
+
*/
|
|
45
|
+
joinThread(request: {
|
|
46
|
+
threadId: string;
|
|
47
|
+
userId: string;
|
|
48
|
+
}): Promise<{
|
|
49
|
+
outcome: 'completed';
|
|
50
|
+
} | {
|
|
51
|
+
outcome: 'failed';
|
|
52
|
+
}>;
|
|
53
|
+
}
|
|
54
|
+
export interface ThreadCreationDeps {
|
|
55
|
+
intents: IntentStore;
|
|
56
|
+
discord: DiscordThreadPort;
|
|
57
|
+
nowMs: () => number;
|
|
58
|
+
}
|
|
59
|
+
export type ThreadCreationResult = {
|
|
60
|
+
outcome: 'created';
|
|
61
|
+
threadId: string;
|
|
62
|
+
} | {
|
|
63
|
+
outcome: 'recovered';
|
|
64
|
+
threadId: string;
|
|
65
|
+
} | {
|
|
66
|
+
outcome: 'conflict';
|
|
67
|
+
} | {
|
|
68
|
+
outcome: 'failed';
|
|
69
|
+
};
|
|
70
|
+
export interface EnsureThreadRequest {
|
|
71
|
+
sourceMessageId: string;
|
|
72
|
+
contentHash: string;
|
|
73
|
+
guildId: string;
|
|
74
|
+
parentChannelId: string;
|
|
75
|
+
threadName: string;
|
|
76
|
+
/** The task author to join into the thread (sidebar visibility). */
|
|
77
|
+
creatorUserId?: string | undefined;
|
|
78
|
+
}
|
|
79
|
+
export declare function createThreadCreationFlow(deps: ThreadCreationDeps): {
|
|
80
|
+
ensureThread(request: EnsureThreadRequest): Promise<ThreadCreationResult>;
|
|
81
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread creation over the source-message intent (design.md §4, §10, anchored-thread
|
|
3
|
+
* thread model). The source Discord message id is the stable claim: the
|
|
4
|
+
* first creator creates a thread ANCHORED to that message (Discord moves it
|
|
5
|
+
* into the thread as its first post — the user's task text opens the
|
|
6
|
+
* thread) and records it on the intent; any redelivery of the same message
|
|
7
|
+
* recovers that thread (from the record, or — across a crash window where
|
|
8
|
+
* the intent exists but carries no thread id — from Discord's deterministic
|
|
9
|
+
* source-message lookup). The author is joined to the thread so it appears
|
|
10
|
+
* in their sidebar (live-tested: "add user to thread so it appears in their
|
|
11
|
+
* sidebar"). Different content under the same message id conflicts, and
|
|
12
|
+
* creation failures surface as values.
|
|
13
|
+
*/
|
|
14
|
+
export function createThreadCreationFlow(deps) {
|
|
15
|
+
/**
|
|
16
|
+
* Best-effort author join on every path that yields a live thread. A join
|
|
17
|
+
* failure never fails the task: the thread exists and reconciliation, not
|
|
18
|
+
* this call, owns Discord-side recovery.
|
|
19
|
+
*/
|
|
20
|
+
async function joinAuthor(request, threadId) {
|
|
21
|
+
if (request.creatorUserId === undefined || request.creatorUserId === '')
|
|
22
|
+
return;
|
|
23
|
+
try {
|
|
24
|
+
await deps.discord.joinThread({ threadId, userId: request.creatorUserId });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
async ensureThread(request) {
|
|
32
|
+
const claim = await deps.intents.claim({
|
|
33
|
+
messageId: request.sourceMessageId,
|
|
34
|
+
contentHash: request.contentHash,
|
|
35
|
+
claimedAtMs: deps.nowMs(),
|
|
36
|
+
});
|
|
37
|
+
if (claim.outcome === 'conflict')
|
|
38
|
+
return { outcome: 'conflict' };
|
|
39
|
+
if (claim.outcome === 'duplicate') {
|
|
40
|
+
const existing = claim.record.threadId;
|
|
41
|
+
if (existing !== undefined && existing !== '') {
|
|
42
|
+
await joinAuthor(request, existing);
|
|
43
|
+
return { outcome: 'recovered', threadId: existing };
|
|
44
|
+
}
|
|
45
|
+
// Intent exists without a thread id: a previous attempt crashed
|
|
46
|
+
// mid-flow. Recover deterministically from Discord.
|
|
47
|
+
const found = await deps.discord.findThreadBySource({
|
|
48
|
+
guildId: request.guildId,
|
|
49
|
+
sourceMessageId: request.sourceMessageId,
|
|
50
|
+
});
|
|
51
|
+
if (found.outcome === 'found') {
|
|
52
|
+
await deps.intents.resolve(request.sourceMessageId, 'succeeded', deps.nowMs());
|
|
53
|
+
// Persist the recovered id like the fresh-create path does, or
|
|
54
|
+
// every redelivery re-enters the crash window and rescans.
|
|
55
|
+
await deps.intents.annotate(request.sourceMessageId, { threadId: found.threadId });
|
|
56
|
+
await joinAuthor(request, found.threadId);
|
|
57
|
+
return { outcome: 'recovered', threadId: found.threadId };
|
|
58
|
+
}
|
|
59
|
+
return { outcome: 'failed' };
|
|
60
|
+
}
|
|
61
|
+
// Fresh claim: create the anchored thread and record it on the intent.
|
|
62
|
+
const created = await deps.discord.createThread({
|
|
63
|
+
parentChannelId: request.parentChannelId,
|
|
64
|
+
name: request.threadName,
|
|
65
|
+
sourceMessageId: request.sourceMessageId,
|
|
66
|
+
});
|
|
67
|
+
if (created.outcome !== 'completed') {
|
|
68
|
+
await deps.intents.resolve(request.sourceMessageId, 'failed', deps.nowMs());
|
|
69
|
+
return { outcome: 'failed' };
|
|
70
|
+
}
|
|
71
|
+
await joinAuthor(request, created.threadId);
|
|
72
|
+
const stored = deps.intents.get(request.sourceMessageId);
|
|
73
|
+
if (stored !== undefined) {
|
|
74
|
+
await deps.intents.resolve(request.sourceMessageId, 'succeeded', deps.nowMs());
|
|
75
|
+
// Persist the thread id on the intent record for later replays.
|
|
76
|
+
await deps.intents.annotate(request.sourceMessageId, { threadId: created.threadId });
|
|
77
|
+
}
|
|
78
|
+
return { outcome: 'created', threadId: created.threadId };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread routing (design.md §4). A thread bound to a Session always routes to
|
|
3
|
+
* that Session and its immutable cwd workspace — channel rebinds cannot move
|
|
4
|
+
* it. Routing prefers the thread binding, falls back to the channel's current
|
|
5
|
+
* workspace for NEW threads, and reports unbound when neither exists. New
|
|
6
|
+
* thread bindings go through the same revision-fenced store so a duplicate
|
|
7
|
+
* Thread-create race cannot rebind an existing thread to a different session.
|
|
8
|
+
*/
|
|
9
|
+
import type { ThreadBinding } from '../state/records.js';
|
|
10
|
+
import { type ThreadBindingScope } from '../state/domain.js';
|
|
11
|
+
import type { BindingStore } from '../state/bindings.js';
|
|
12
|
+
export interface ThreadRoutingDeps {
|
|
13
|
+
threadBindings: BindingStore<ThreadBinding>;
|
|
14
|
+
applicationId: string;
|
|
15
|
+
}
|
|
16
|
+
export type ThreadRoute = {
|
|
17
|
+
route: 'existing-session';
|
|
18
|
+
sessionId: string;
|
|
19
|
+
workspaceId: string;
|
|
20
|
+
} | {
|
|
21
|
+
route: 'new-session';
|
|
22
|
+
workspaceId: string;
|
|
23
|
+
} | {
|
|
24
|
+
route: 'unbound';
|
|
25
|
+
};
|
|
26
|
+
export interface ThreadRoutingService {
|
|
27
|
+
bindThread(request: {
|
|
28
|
+
scope: ThreadBindingScope;
|
|
29
|
+
request: {
|
|
30
|
+
sessionId: string;
|
|
31
|
+
workspaceId: string;
|
|
32
|
+
createdBy: string;
|
|
33
|
+
nowMs: number;
|
|
34
|
+
};
|
|
35
|
+
}): Promise<{
|
|
36
|
+
ok: true;
|
|
37
|
+
binding: ThreadBinding;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
error: 'already-bound' | 'stale-revision' | 'not-bound';
|
|
41
|
+
}>;
|
|
42
|
+
route(input: {
|
|
43
|
+
guildId: string;
|
|
44
|
+
threadId: string;
|
|
45
|
+
/** The channel's current binding; undefined when the channel is unbound. */
|
|
46
|
+
channelWorkspaceId: string | undefined;
|
|
47
|
+
}): ThreadRoute;
|
|
48
|
+
}
|
|
49
|
+
export declare function createThreadRoutingService(deps: ThreadRoutingDeps): ThreadRoutingService;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread routing (design.md §4). A thread bound to a Session always routes to
|
|
3
|
+
* that Session and its immutable cwd workspace — channel rebinds cannot move
|
|
4
|
+
* it. Routing prefers the thread binding, falls back to the channel's current
|
|
5
|
+
* workspace for NEW threads, and reports unbound when neither exists. New
|
|
6
|
+
* thread bindings go through the same revision-fenced store so a duplicate
|
|
7
|
+
* Thread-create race cannot rebind an existing thread to a different session.
|
|
8
|
+
*/
|
|
9
|
+
import { threadBindingKey } from '../state/domain.js';
|
|
10
|
+
export function createThreadRoutingService(deps) {
|
|
11
|
+
return {
|
|
12
|
+
async bindThread({ scope, request }) {
|
|
13
|
+
const result = await deps.threadBindings.bind(threadBindingKey(scope), {
|
|
14
|
+
sessionId: request.sessionId,
|
|
15
|
+
workspaceId: request.workspaceId,
|
|
16
|
+
createdBy: request.createdBy,
|
|
17
|
+
createdAtMs: request.nowMs,
|
|
18
|
+
});
|
|
19
|
+
if (result.ok)
|
|
20
|
+
return { ok: true, binding: result.binding };
|
|
21
|
+
return { ok: false, error: result.error };
|
|
22
|
+
},
|
|
23
|
+
route({ guildId, threadId, channelWorkspaceId }) {
|
|
24
|
+
const binding = deps.threadBindings.get(threadBindingKey({ applicationId: deps.applicationId, guildId, threadId }));
|
|
25
|
+
if (binding !== undefined) {
|
|
26
|
+
return { route: 'existing-session', sessionId: binding.sessionId, workspaceId: binding.workspaceId };
|
|
27
|
+
}
|
|
28
|
+
if (channelWorkspaceId !== undefined) {
|
|
29
|
+
return { route: 'new-session', workspaceId: channelWorkspaceId };
|
|
30
|
+
}
|
|
31
|
+
return { route: 'unbound' };
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|