@rmrdeveloper/sideroom-pi 8.11.0 → 8.13.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/README.md +49 -5
- package/assets/artifacts/GUIDELINES_TEMPLATE.md +3 -3
- package/assets/visual-context/browser/INSTALL.md +69 -0
- package/assets/visual-context/browser/background.js +578 -0
- package/assets/visual-context/browser/content.js +198 -0
- package/assets/visual-context/browser/icons/README.md +9 -0
- package/assets/visual-context/browser/icons/icon-128.png +0 -0
- package/assets/visual-context/browser/icons/icon-16.png +0 -0
- package/assets/visual-context/browser/icons/icon-32.png +0 -0
- package/assets/visual-context/browser/icons/icon-48.png +0 -0
- package/assets/visual-context/browser/manifest.json +20 -0
- package/assets/visual-context/browser/popup.css +19 -0
- package/assets/visual-context/browser/popup.html +27 -0
- package/assets/visual-context/browser/popup.js +141 -0
- package/extensions/done/guard.ts +42 -37
- package/extensions/done/index.ts +3 -2
- package/extensions/done/model.ts +1 -5
- package/extensions/explain/guard.ts +13 -11
- package/extensions/explain/model.ts +2 -25
- package/extensions/guidelines/review.ts +31 -13
- package/extensions/jev/client.ts +22 -1
- package/extensions/jev/context.ts +36 -3
- package/extensions/jev/guard.ts +113 -34
- package/extensions/jev/model.ts +90 -12
- package/extensions/jev/workflow.ts +188 -0
- package/extensions/persona/execute.ts +4 -6
- package/extensions/persona/index.ts +3 -2
- package/extensions/persona/model.ts +1 -31
- package/extensions/persona/prompt.ts +3 -1
- package/extensions/shared/settle.ts +63 -13
- package/extensions/visual-context/bridge.ts +240 -0
- package/extensions/visual-context/broker.ts +292 -0
- package/extensions/visual-context/envelope.ts +34 -0
- package/extensions/visual-context/index.ts +328 -0
- package/extensions/visual-context/literal.ts +34 -0
- package/extensions/visual-context/markup.ts +104 -0
- package/extensions/visual-context/match.ts +222 -0
- package/extensions/visual-context/model.ts +180 -0
- package/extensions/visual-context/pairing.ts +114 -0
- package/extensions/visual-context/parse-astro.ts +166 -0
- package/extensions/visual-context/parse-html.ts +72 -0
- package/extensions/visual-context/parse-jsx.ts +157 -0
- package/extensions/visual-context/parse-svelte.ts +128 -0
- package/extensions/visual-context/parse-vue.ts +133 -0
- package/extensions/visual-context/session.ts +192 -0
- package/extensions/visual-context/source.ts +197 -0
- package/extensions/visual-context/ui.ts +57 -0
- package/package.json +16 -7
- package/skills/sideroom-architecture/SKILL.md +5 -3
- package/skills/sideroom-codebase-design/LICENSE.txt +21 -0
- package/skills/sideroom-codebase-design/SKILL.md +151 -0
- package/skills/sideroom-codebase-design/references/growth.md +62 -0
- package/skills/sideroom-domain-modeling/SKILL.md +1 -1
- package/skills/sideroom-domain-scaffold/SKILL.md +6 -4
- package/skills/sideroom-grill/SKILL.md +6 -4
- package/skills/sideroom-guidelines/SKILL.md +9 -7
- package/skills/sideroom-guidelines/references/languages/go.md +3 -3
- package/skills/sideroom-guidelines/references/languages/java.md +3 -3
- package/skills/sideroom-guidelines/references/languages/javascript.md +25 -3
- package/skills/sideroom-guidelines/references/languages/php-laravel.md +3 -3
- package/skills/sideroom-guidelines/references/languages/python.md +3 -3
- package/skills/sideroom-guidelines/references/languages/rust.md +3 -3
- package/skills/sideroom-guidelines/references/languages/typescript.md +27 -3
- package/skills/sideroom-persona/SKILL.md +15 -4
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { type AskParams, parseAskParams } from '../ask/model.ts';
|
|
2
|
+
import {
|
|
3
|
+
parseTodoParams,
|
|
4
|
+
TODO_STATUS,
|
|
5
|
+
type TodoItem,
|
|
6
|
+
type TodoToolParameters,
|
|
7
|
+
} from '../todo/model.ts';
|
|
8
|
+
import {
|
|
9
|
+
JEV_MODEL,
|
|
10
|
+
type JevFileState,
|
|
11
|
+
type JevNoulQuestion,
|
|
12
|
+
type JevRequestBody,
|
|
13
|
+
requestFitsBudget,
|
|
14
|
+
} from './model.ts';
|
|
15
|
+
|
|
16
|
+
const MAX_EVIDENCE_FILES = 4;
|
|
17
|
+
|
|
18
|
+
type ChangedFileEvidence = Pick<JevFileState, 'file' | 'change'>;
|
|
19
|
+
|
|
20
|
+
export interface WorkflowEvidence {
|
|
21
|
+
readonly files: Map<string, ChangedFileEvidence>;
|
|
22
|
+
readonly completed: Map<string, string>;
|
|
23
|
+
partial: boolean;
|
|
24
|
+
checks: 'unknown' | 'passed' | 'failed' | 'stale';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createWorkflowEvidence(): WorkflowEvidence {
|
|
28
|
+
return {
|
|
29
|
+
files: new Map(),
|
|
30
|
+
completed: new Map(),
|
|
31
|
+
partial: false,
|
|
32
|
+
checks: 'unknown',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resetWorkflowEvidence(evidence: WorkflowEvidence): void {
|
|
37
|
+
evidence.files.clear();
|
|
38
|
+
evidence.completed.clear();
|
|
39
|
+
evidence.partial = false;
|
|
40
|
+
evidence.checks = 'unknown';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function recordFileEvidence(
|
|
44
|
+
evidence: WorkflowEvidence,
|
|
45
|
+
file: JevFileState,
|
|
46
|
+
): void {
|
|
47
|
+
evidence.checks = 'stale';
|
|
48
|
+
evidence.files.delete(file.file.path);
|
|
49
|
+
evidence.files.set(file.file.path, { file: file.file, change: file.change });
|
|
50
|
+
if (evidence.files.size > MAX_EVIDENCE_FILES) {
|
|
51
|
+
const oldest = evidence.files.keys().next().value;
|
|
52
|
+
if (oldest !== undefined) {
|
|
53
|
+
evidence.files.delete(oldest);
|
|
54
|
+
}
|
|
55
|
+
evidence.partial = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function question(instructions: string, violation: string): JevNoulQuestion {
|
|
60
|
+
return {
|
|
61
|
+
type: 'noul',
|
|
62
|
+
instructions,
|
|
63
|
+
criteria: {
|
|
64
|
+
true: violation,
|
|
65
|
+
false:
|
|
66
|
+
'No concrete contradiction is visible, or the supplied evidence is insufficient',
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildAskReview(input: unknown): JevRequestBody | undefined {
|
|
72
|
+
const parsed = parseAskParams(input as AskParams);
|
|
73
|
+
if (!parsed.ok) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const request: JevRequestBody = {
|
|
77
|
+
model: JEV_MODEL,
|
|
78
|
+
state: { kind: 'questionnaire', questions: parsed.questions },
|
|
79
|
+
questions: {
|
|
80
|
+
'question-options': question(
|
|
81
|
+
'Do the supplied questions contain overlapping single-selection options or bundle independent decisions so no option can express a reasonable combination? Multiple selections may intentionally overlap. Judge only the question text, not unseen requirements.',
|
|
82
|
+
'The wording visibly prevents a clear independent choice',
|
|
83
|
+
),
|
|
84
|
+
'question-recommendation': question(
|
|
85
|
+
'Does a recommended option claim factual certainty or decisive cost/performance figures without support in the supplied question, or contradict its stated constraints? A preference alone need not include proof. Do not assume unseen project evidence is absent; flag only a demonstrably unsupported assertion or contradiction in the wording.',
|
|
86
|
+
'The recommendation visibly contradicts stated constraints or asserts an unsupported decisive fact',
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
return requestFitsBudget(request) ? request : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function completedItems(
|
|
94
|
+
details: unknown,
|
|
95
|
+
): readonly TodoItem[] | undefined {
|
|
96
|
+
if (typeof details !== 'object' || details === null || 'error' in details) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const parsed = parseTodoParams({
|
|
100
|
+
action: 'propose',
|
|
101
|
+
items: 'items' in details ? details.items : undefined,
|
|
102
|
+
} as TodoToolParameters);
|
|
103
|
+
if (!parsed.ok || parsed.params.action !== 'propose') {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
return parsed.params.items.filter(
|
|
107
|
+
(item) => item.status === TODO_STATUS.completed,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function buildBoardReview({
|
|
112
|
+
items,
|
|
113
|
+
evidence,
|
|
114
|
+
changedIds,
|
|
115
|
+
}: {
|
|
116
|
+
readonly items: readonly TodoItem[];
|
|
117
|
+
readonly evidence: WorkflowEvidence;
|
|
118
|
+
readonly changedIds?: ReadonlySet<string>;
|
|
119
|
+
}): JevRequestBody | undefined {
|
|
120
|
+
const newlyCompleted = items.filter(
|
|
121
|
+
(item) =>
|
|
122
|
+
(changedIds === undefined || changedIds.has(item.id)) &&
|
|
123
|
+
evidence.completed.get(item.id) !== item.content,
|
|
124
|
+
);
|
|
125
|
+
evidence.completed.clear();
|
|
126
|
+
for (const item of items) {
|
|
127
|
+
evidence.completed.set(item.id, item.content);
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
newlyCompleted.length === 0 ||
|
|
131
|
+
(evidence.files.size === 0 &&
|
|
132
|
+
evidence.checks !== 'passed' &&
|
|
133
|
+
evidence.checks !== 'failed')
|
|
134
|
+
) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
const files: ChangedFileEvidence[] = [];
|
|
138
|
+
const state = {
|
|
139
|
+
kind: 'completion',
|
|
140
|
+
items: newlyCompleted,
|
|
141
|
+
evidence: { files, checks: evidence.checks, partial: evidence.partial },
|
|
142
|
+
};
|
|
143
|
+
const request: JevRequestBody = {
|
|
144
|
+
model: JEV_MODEL,
|
|
145
|
+
state,
|
|
146
|
+
questions: {
|
|
147
|
+
'board-completion': question(
|
|
148
|
+
'Do the newly completed tasks make a concrete claim contradicted by the supplied code changes or check status? Check status concerns only the detected project check in this user turn and becomes stale after a mutation. Missing evidence, unknown/stale checks and partial files do not prove unfinished work. Do not require every task to involve code or tests. Flag only an explicit mismatch, such as claiming checks passed when the observed latest checks failed.',
|
|
149
|
+
'A completed task claims behavior or a check result demonstrably contradicted by visible evidence',
|
|
150
|
+
),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
for (const file of evidence.files.values()) {
|
|
154
|
+
files.push(file);
|
|
155
|
+
if (!requestFitsBudget(request)) {
|
|
156
|
+
files.pop();
|
|
157
|
+
state.evidence.partial = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return (files.length > 0 ||
|
|
161
|
+
evidence.checks === 'passed' ||
|
|
162
|
+
evidence.checks === 'failed') &&
|
|
163
|
+
requestFitsBudget(request)
|
|
164
|
+
? request
|
|
165
|
+
: undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function completionIds(input: unknown): ReadonlySet<string> {
|
|
169
|
+
const parsed = parseTodoParams(input as TodoToolParameters);
|
|
170
|
+
if (!parsed.ok) {
|
|
171
|
+
return new Set();
|
|
172
|
+
}
|
|
173
|
+
if (parsed.params.action === 'propose') {
|
|
174
|
+
return new Set(
|
|
175
|
+
parsed.params.items
|
|
176
|
+
.filter((item) => item.status === TODO_STATUS.completed)
|
|
177
|
+
.map((item) => item.id),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return new Set(
|
|
181
|
+
parsed.params.patches
|
|
182
|
+
.filter(
|
|
183
|
+
(patch) =>
|
|
184
|
+
patch.status === TODO_STATUS.completed || patch.content !== undefined,
|
|
185
|
+
)
|
|
186
|
+
.map((patch) => patch.id),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
personaToolDetails,
|
|
5
|
-
} from './model.ts';
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { PERSONA_SKILL_PATH } from './catalog.ts';
|
|
3
|
+
import { type PersonaToolDetails, personaToolDetails } from './model.ts';
|
|
6
4
|
|
|
7
5
|
export interface PersonaToolResult {
|
|
8
6
|
readonly content: { readonly type: 'text'; readonly text: string }[];
|
|
@@ -11,7 +9,7 @@ export interface PersonaToolResult {
|
|
|
11
9
|
|
|
12
10
|
export function executePersona(): PersonaToolResult {
|
|
13
11
|
return {
|
|
14
|
-
content: [{ type: 'text', text:
|
|
12
|
+
content: [{ type: 'text', text: readFileSync(PERSONA_SKILL_PATH, 'utf8') }],
|
|
15
13
|
details: personaToolDetails(),
|
|
16
14
|
};
|
|
17
15
|
}
|
|
@@ -15,9 +15,10 @@ import { personaStatusLabel } from './model.ts';
|
|
|
15
15
|
import { appendPersonaReminder } from './prompt.ts';
|
|
16
16
|
|
|
17
17
|
export const PERSONA_DESCRIPTION =
|
|
18
|
-
|
|
18
|
+
'Load the complete Sideroom writing guide before a substantial explanation, findings report, implementation summary, or user-facing documentation; also use when asked for clearer, shorter, or less technical wording. Reuse while in context.';
|
|
19
19
|
|
|
20
|
-
export const PERSONA_PROMPT_SNIPPET =
|
|
20
|
+
export const PERSONA_PROMPT_SNIPPET =
|
|
21
|
+
'Load the complete writing guide before substantial user-facing prose.';
|
|
21
22
|
|
|
22
23
|
// Pi loads extensions/*/index.ts through export default.
|
|
23
24
|
export default function registerPersona(pi: ExtensionAPI): void {
|
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
PERSONA_SKILL_PATH,
|
|
3
|
-
PERSONA_TOOL_NAME,
|
|
4
|
-
PROHIBITIONS,
|
|
5
|
-
type Prohibition,
|
|
6
|
-
TEXT_SCOPE,
|
|
7
|
-
VOICE_RULES,
|
|
8
|
-
} from './catalog.ts';
|
|
1
|
+
import { PERSONA_TOOL_NAME, PROHIBITIONS, VOICE_RULES } from './catalog.ts';
|
|
9
2
|
import type { ProhibitionViolation } from './checks.ts';
|
|
10
3
|
|
|
11
4
|
// Pi narrows these blocks before handing the message over; the shape is
|
|
@@ -45,22 +38,6 @@ export function personaToolDetails(): PersonaToolDetails {
|
|
|
45
38
|
};
|
|
46
39
|
}
|
|
47
40
|
|
|
48
|
-
export function formatPersonaDetail(): string {
|
|
49
|
-
return [
|
|
50
|
-
'Sideroom persona: one always-on voice; no profiles or switching.',
|
|
51
|
-
'',
|
|
52
|
-
'Voice rules',
|
|
53
|
-
...VOICE_RULES.map((rule) => `- ${rule.instruction}`),
|
|
54
|
-
'',
|
|
55
|
-
'Hard prohibitions',
|
|
56
|
-
...PROHIBITIONS.map(
|
|
57
|
-
(prohibition) => `- ${prohibition.rule} (${enforcementOf(prohibition)})`,
|
|
58
|
-
),
|
|
59
|
-
'',
|
|
60
|
-
`Complete guide: ${PERSONA_SKILL_PATH}`,
|
|
61
|
-
].join('\n');
|
|
62
|
-
}
|
|
63
|
-
|
|
64
41
|
export function formatBlockReason(
|
|
65
42
|
path: string,
|
|
66
43
|
violations: readonly ProhibitionViolation[],
|
|
@@ -112,10 +89,3 @@ export function assistantMessageText(content: readonly TextBlock[]): string {
|
|
|
112
89
|
}
|
|
113
90
|
return parts.join('\n');
|
|
114
91
|
}
|
|
115
|
-
|
|
116
|
-
function enforcementOf(prohibition: Prohibition): string {
|
|
117
|
-
if (prohibition.scopes.includes(TEXT_SCOPE.artifact)) {
|
|
118
|
-
return 'blocked on write and edit';
|
|
119
|
-
}
|
|
120
|
-
return 'corrected with a steer';
|
|
121
|
-
}
|
|
@@ -23,12 +23,14 @@ export const PERSONA_EXAMPLES = [
|
|
|
23
23
|
] as const;
|
|
24
24
|
|
|
25
25
|
export const PERSONA_REMINDER = `${PERSONA_REMINDER_HEADING}
|
|
26
|
+
Apply this voice to progress messages, final answers, and user-facing documentation.
|
|
26
27
|
Voice: ${VOICE_SUMMARY}
|
|
27
28
|
Never: ${PROHIBITION_SUMMARY}
|
|
28
29
|
${ENFORCEMENT_SUMMARY}
|
|
29
30
|
Examples:
|
|
30
31
|
${PERSONA_EXAMPLES.join('\n')}
|
|
31
|
-
|
|
32
|
+
Before the first substantial explanation or report, call ${PERSONA_TOOL_NAME} or read ${PERSONA_SKILL_PATH}; reuse the complete guide while it remains in context.
|
|
33
|
+
Before sending prose, check: outcome first, necessary terms explained, no repetition or invented labels. Preserve evidence and requested detail.`;
|
|
32
34
|
|
|
33
35
|
export function appendPersonaReminder(systemPrompt: string): string {
|
|
34
36
|
if (systemPrompt.includes(PERSONA_REMINDER_HEADING)) {
|
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AgentBeforeSettleEvent,
|
|
3
3
|
AgentBeforeSettleEventResult,
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionContext,
|
|
4
6
|
InputEvent,
|
|
5
7
|
InputSource,
|
|
6
8
|
} from '@earendil-works/pi-coding-agent';
|
|
7
9
|
|
|
8
10
|
const COMPLETED_OUTCOME = 'completed';
|
|
9
|
-
const USER_ROLE = 'user';
|
|
10
11
|
const CUSTOM_MESSAGE_ENTRY = 'custom_message';
|
|
12
|
+
const SETTLE_PRIORITY_EVENT = 'sideroom:settle-priority';
|
|
13
|
+
|
|
14
|
+
export const SETTLE_PRIORITY = { review: 0, done: 1, explain: 2 } as const;
|
|
15
|
+
|
|
16
|
+
interface SettlePriorityRequest {
|
|
17
|
+
readonly ctx: ExtensionContext;
|
|
18
|
+
readonly priorities: Set<number>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface SettleGuard {
|
|
22
|
+
readonly priority: number;
|
|
23
|
+
readonly pending: (ctx: ExtensionContext) => boolean;
|
|
24
|
+
readonly handle: (
|
|
25
|
+
event: AgentBeforeSettleEvent,
|
|
26
|
+
ctx: ExtensionContext,
|
|
27
|
+
) => AgentBeforeSettleEventResult | undefined;
|
|
28
|
+
}
|
|
11
29
|
const USER_INPUT_SOURCES: ReadonlySet<InputSource> = new Set([
|
|
12
30
|
'interactive',
|
|
13
31
|
'rpc',
|
|
@@ -22,24 +40,56 @@ export function startsUserTurn(event: InputEvent): boolean {
|
|
|
22
40
|
);
|
|
23
41
|
}
|
|
24
42
|
|
|
25
|
-
//
|
|
26
|
-
//
|
|
43
|
+
// Any queued message or an earlier handler's continuation still has work to
|
|
44
|
+
// run. Yield without consuming the guard's state until a quiet boundary.
|
|
27
45
|
export function acceptsSettleSteer(event: AgentBeforeSettleEvent): boolean {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
(message) => message.role === USER_ROLE,
|
|
46
|
+
return (
|
|
47
|
+
event.outcome === COMPLETED_OUTCOME &&
|
|
48
|
+
!event.continue &&
|
|
49
|
+
event.context.pendingMessages.length === 0
|
|
33
50
|
);
|
|
34
51
|
}
|
|
35
52
|
|
|
53
|
+
// Pi gives each extension its own API object. Its synchronous event bus is
|
|
54
|
+
// shared, so ask which stages are pending rather than relying on load order
|
|
55
|
+
// or module-global state that would leak across sessions and reloads.
|
|
56
|
+
export function registerSettleGuard(
|
|
57
|
+
pi: ExtensionAPI,
|
|
58
|
+
guard: SettleGuard,
|
|
59
|
+
): void {
|
|
60
|
+
pi.events.on(SETTLE_PRIORITY_EVENT, (payload) => {
|
|
61
|
+
const request = payload as SettlePriorityRequest;
|
|
62
|
+
if (guard.pending(request.ctx)) {
|
|
63
|
+
request.priorities.add(guard.priority);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
pi.on('agent_before_settle', (event, ctx) => {
|
|
68
|
+
if (!acceptsSettleSteer(event) || !guard.pending(ctx)) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const request: SettlePriorityRequest = { ctx, priorities: new Set() };
|
|
72
|
+
pi.events.emit(SETTLE_PRIORITY_EVENT, request);
|
|
73
|
+
if ([...request.priorities].some((priority) => priority < guard.priority)) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
return guard.handle(event, ctx);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
36
80
|
// Handlers chain on agent_before_settle, so keep the entries earlier handlers
|
|
37
81
|
// proposed; replacing them would drop another extension's steer.
|
|
38
|
-
|
|
39
|
-
event: AgentBeforeSettleEvent
|
|
40
|
-
customType: string
|
|
41
|
-
content: string
|
|
42
|
-
|
|
82
|
+
interface HiddenSteerOptions {
|
|
83
|
+
readonly event: AgentBeforeSettleEvent;
|
|
84
|
+
readonly customType: string;
|
|
85
|
+
readonly content: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function withHiddenSteer({
|
|
89
|
+
event,
|
|
90
|
+
customType,
|
|
91
|
+
content,
|
|
92
|
+
}: HiddenSteerOptions): AgentBeforeSettleEventResult {
|
|
43
93
|
return {
|
|
44
94
|
entries: [
|
|
45
95
|
...event.entries,
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { WebSocket } from 'ws';
|
|
2
|
+
import { type LocalBroker, startBroker } from './broker.ts';
|
|
3
|
+
import { parseEnvelope, send } from './envelope.ts';
|
|
4
|
+
import {
|
|
5
|
+
MESSAGE_TYPE,
|
|
6
|
+
PEER_ROLE,
|
|
7
|
+
PI_ORIGIN,
|
|
8
|
+
PROTOCOL_VERSION,
|
|
9
|
+
parseSelection,
|
|
10
|
+
type SessionDescriptor,
|
|
11
|
+
type VisualSelection,
|
|
12
|
+
} from './model.ts';
|
|
13
|
+
import { endpoint, hasCode, type PairingIdentity } from './pairing.ts';
|
|
14
|
+
|
|
15
|
+
const RECONNECT_MS = 1_000;
|
|
16
|
+
const HEARTBEAT_MS = 20_000;
|
|
17
|
+
const STARTUP_TIMEOUT_MS = 10_000;
|
|
18
|
+
export interface VisualBridge {
|
|
19
|
+
update(session: SessionDescriptor): void;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
interface SessionConnectionOptions {
|
|
23
|
+
readonly identity: PairingIdentity;
|
|
24
|
+
readonly session: SessionDescriptor;
|
|
25
|
+
readonly onSelection: (
|
|
26
|
+
selection: VisualSelection,
|
|
27
|
+
revision: string,
|
|
28
|
+
) => {
|
|
29
|
+
readonly candidates: number;
|
|
30
|
+
};
|
|
31
|
+
readonly onError: (error: Error) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function connectSession(
|
|
35
|
+
options: SessionConnectionOptions,
|
|
36
|
+
): Promise<VisualBridge> {
|
|
37
|
+
let session = options.session;
|
|
38
|
+
let broker: LocalBroker | undefined;
|
|
39
|
+
let socket: WebSocket | undefined;
|
|
40
|
+
let retry: NodeJS.Timeout | undefined;
|
|
41
|
+
let heartbeat: NodeJS.Timeout | undefined;
|
|
42
|
+
let closed = false;
|
|
43
|
+
let authenticated = false;
|
|
44
|
+
let registered = false;
|
|
45
|
+
let resolveStartup: () => void;
|
|
46
|
+
let rejectStartup: (error: Error) => void;
|
|
47
|
+
const startup = new Promise<void>((resolve, reject) => {
|
|
48
|
+
resolveStartup = resolve;
|
|
49
|
+
rejectStartup = reject;
|
|
50
|
+
});
|
|
51
|
+
const report = (cause: unknown): void => {
|
|
52
|
+
if (closed) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const error =
|
|
56
|
+
cause instanceof Error
|
|
57
|
+
? cause
|
|
58
|
+
: new Error('Cannot connect the Pi session.', { cause });
|
|
59
|
+
if (!registered) {
|
|
60
|
+
rejectStartup(error);
|
|
61
|
+
} else {
|
|
62
|
+
options.onError(error);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const register = (): void => {
|
|
66
|
+
if (socket && authenticated) {
|
|
67
|
+
send(socket, { type: MESSAGE_TYPE.register, session });
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
async function attach(): Promise<void> {
|
|
71
|
+
if (closed) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!broker) {
|
|
75
|
+
try {
|
|
76
|
+
const created = await startBroker({
|
|
77
|
+
identity: options.identity,
|
|
78
|
+
onError: options.onError,
|
|
79
|
+
});
|
|
80
|
+
if (closed) {
|
|
81
|
+
await created.close();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
broker = created;
|
|
85
|
+
} catch (cause) {
|
|
86
|
+
if (!hasCode(cause, 'EADDRINUSE')) {
|
|
87
|
+
report(cause);
|
|
88
|
+
scheduleRetry();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (closed) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const connection = new WebSocket(endpoint(options.identity), {
|
|
97
|
+
origin: PI_ORIGIN,
|
|
98
|
+
maxPayload: 32_768,
|
|
99
|
+
});
|
|
100
|
+
socket = connection;
|
|
101
|
+
authenticated = false;
|
|
102
|
+
const authenticationTimeout = setTimeout(() => {
|
|
103
|
+
report(new Error('The local browser bridge did not authenticate.'));
|
|
104
|
+
connection.terminate();
|
|
105
|
+
}, STARTUP_TIMEOUT_MS);
|
|
106
|
+
authenticationTimeout.unref();
|
|
107
|
+
connection.on('open', () =>
|
|
108
|
+
send(connection, {
|
|
109
|
+
type: MESSAGE_TYPE.authenticate,
|
|
110
|
+
role: PEER_ROLE.pi,
|
|
111
|
+
version: PROTOCOL_VERSION,
|
|
112
|
+
token: options.identity.token,
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
connection.on('error', report);
|
|
116
|
+
connection.on('close', () => {
|
|
117
|
+
clearTimeout(authenticationTimeout);
|
|
118
|
+
clearInterval(heartbeat);
|
|
119
|
+
authenticated = false;
|
|
120
|
+
scheduleRetry();
|
|
121
|
+
});
|
|
122
|
+
connection.on('message', (bytes, binary) => {
|
|
123
|
+
try {
|
|
124
|
+
const message = parseEnvelope(bytes.toString(), binary);
|
|
125
|
+
if (
|
|
126
|
+
message.type === MESSAGE_TYPE.ready &&
|
|
127
|
+
message.version === PROTOCOL_VERSION
|
|
128
|
+
) {
|
|
129
|
+
clearTimeout(authenticationTimeout);
|
|
130
|
+
authenticated = true;
|
|
131
|
+
heartbeat = setInterval(
|
|
132
|
+
() => send(connection, { type: MESSAGE_TYPE.ping }),
|
|
133
|
+
HEARTBEAT_MS,
|
|
134
|
+
);
|
|
135
|
+
heartbeat.unref();
|
|
136
|
+
register();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (
|
|
140
|
+
message.type === MESSAGE_TYPE.register &&
|
|
141
|
+
message.revision === session.revision
|
|
142
|
+
) {
|
|
143
|
+
registered = true;
|
|
144
|
+
resolveStartup();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (message.type === MESSAGE_TYPE.error) {
|
|
148
|
+
report(new Error(String(message.message)));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (message.type !== MESSAGE_TYPE.deliver) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
if (closed || message.revision !== session.revision) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
'The Pi conversation changed. Select the element again.',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const receipt = options.onSelection(
|
|
161
|
+
parseSelection(message.selection),
|
|
162
|
+
session.revision,
|
|
163
|
+
);
|
|
164
|
+
send(connection, {
|
|
165
|
+
type: MESSAGE_TYPE.result,
|
|
166
|
+
requestId: message.requestId,
|
|
167
|
+
candidates: receipt.candidates,
|
|
168
|
+
});
|
|
169
|
+
} catch (cause) {
|
|
170
|
+
const error =
|
|
171
|
+
cause instanceof Error
|
|
172
|
+
? cause
|
|
173
|
+
: new Error('Cannot receive browser selection.', { cause });
|
|
174
|
+
options.onError(error);
|
|
175
|
+
send(connection, {
|
|
176
|
+
type: MESSAGE_TYPE.result,
|
|
177
|
+
requestId: message.requestId,
|
|
178
|
+
error: error.message,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
} catch (cause) {
|
|
182
|
+
report(cause);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function scheduleRetry(): void {
|
|
187
|
+
if (closed || retry) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
retry = setTimeout(() => {
|
|
191
|
+
retry = undefined;
|
|
192
|
+
void attach().catch(report);
|
|
193
|
+
}, RECONNECT_MS);
|
|
194
|
+
retry.unref();
|
|
195
|
+
}
|
|
196
|
+
let closing: Promise<void> | undefined;
|
|
197
|
+
const bridge: VisualBridge = {
|
|
198
|
+
update(next) {
|
|
199
|
+
session = next;
|
|
200
|
+
register();
|
|
201
|
+
},
|
|
202
|
+
close() {
|
|
203
|
+
if (closing) {
|
|
204
|
+
return closing;
|
|
205
|
+
}
|
|
206
|
+
closed = true;
|
|
207
|
+
clearTimeout(retry);
|
|
208
|
+
clearInterval(heartbeat);
|
|
209
|
+
closing = (async () => {
|
|
210
|
+
if (socket && socket.readyState !== WebSocket.CLOSED) {
|
|
211
|
+
const disconnected = new Promise<void>((resolve) =>
|
|
212
|
+
socket?.once('close', resolve),
|
|
213
|
+
);
|
|
214
|
+
socket.terminate();
|
|
215
|
+
await disconnected;
|
|
216
|
+
}
|
|
217
|
+
await broker?.close();
|
|
218
|
+
})();
|
|
219
|
+
return closing;
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
const deadline = setTimeout(
|
|
223
|
+
() =>
|
|
224
|
+
rejectStartup(
|
|
225
|
+
new Error('The Pi session could not join the local browser bridge.'),
|
|
226
|
+
),
|
|
227
|
+
STARTUP_TIMEOUT_MS,
|
|
228
|
+
);
|
|
229
|
+
deadline.unref();
|
|
230
|
+
void attach().catch(report);
|
|
231
|
+
try {
|
|
232
|
+
await startup;
|
|
233
|
+
return bridge;
|
|
234
|
+
} catch (cause) {
|
|
235
|
+
await bridge.close();
|
|
236
|
+
throw cause;
|
|
237
|
+
} finally {
|
|
238
|
+
clearTimeout(deadline);
|
|
239
|
+
}
|
|
240
|
+
}
|