@hybridlabor-api/aos 4.17.0 → 4.18.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/.claude/hooks/aos-bus.mjs +8 -4
- package/.claude/hooks/go-gate.mjs +1 -1
- package/.claude/hooks/go-token.mjs +17 -2
- package/.claude/hooks/memb-inject.mjs +61 -37
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/commands/bdb-aos-plan.md +1 -1
- package/README.md +1 -0
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/bin/aos-acp.mjs +27 -1
- package/bin/aos-doctor.mjs +36 -1
- package/bin/aos-uninstall.mjs +16 -2
- package/bin/go-check.mjs +79 -0
- package/bin/guarded-patterns.json +106 -0
- package/commands/plan.md +1 -1
- package/docs/codenotch.md +44 -0
- package/docs/codex-gate-smoke.md +43 -0
- package/docs/delegation-routing.md +32 -0
- package/docs/go-check.md +60 -0
- package/docs/master-session-acp.md +2 -0
- package/docs/opencode-setup.md +18 -0
- package/installer.js +129 -70
- package/lib/codenotch.js +389 -0
- package/lib/retired-skills.js +101 -0
- package/mcps/mcsc/README.md +1 -1
- package/mcps/mcsc/packages/core/src/adapters/agy.js +3 -1
- package/mcps/mcsc/packages/core/src/adapters/codex.js +2 -1
- package/mcps/mcsc/packages/core/src/adapters/opencode.js +2 -1
- package/mcps/mcsc/packages/core/src/depth.js +16 -0
- package/mcps/mcsc/packages/mcp/server.js +15 -2
- package/package.json +2 -2
- package/plugin-commands.json +1 -2
- package/plugin.json +1 -4
- package/plugins/bdb-aos-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/bdb-aos-codex/skills/plan/SKILL.md +1 -1
- package/scripts/codex-gate-smoke.mjs +73 -0
- package/skills/basic/master-session/SKILL.md +11 -0
- package/skills/global_config/agenttrail/SKILL.md +3 -1
- package/skills/global_config/agenttrail/bin/agenttrail.mjs +255 -117
- package/skills/global_config/agenttrail/bin/ensure.mjs +60 -40
- package/skills/global_config/agenttrail/bin/repoid.mjs +70 -0
- package/skills/global_config/agenttrail/public/index.html +9 -1
- package/skills/global_config/aos-setup/scripts/aos-doctor.mjs +1 -1
- package/skills/global_config/bdb-memb-mcp/SKILL.md +5 -4
- package/skills/global_config/bdb-visual-edit/SKILL.md +28 -32
- package/skills/global_config/bdb-visual-edit/references/vite-react-source-attr.md +2 -2
- package/skills/global_config/bdb-visual-edit/scripts/locate-source.mjs +135 -0
- package/skills/global_config/bdb-visual-edit/scripts/sanitize-element.mjs +30 -2
- package/skills/global_config/mcsc/SKILL.md +9 -1
- package/skills/global_config/plan-arbiter/SKILL.md +1 -1
- package/skills/global_config/plan-canvas/SKILL.md +42 -4
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/README.md +1 -1
- package/skills/global_config/plan-canvas/scripts/lib/plan-builder/render.js +2 -2
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/annotate-client/geometry.js +76 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/annotate-client/index.js +596 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/annotate-client/model.js +192 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/annotate-client/toolbar.js +99 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/annotate-server.js +282 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/annotation-schema.js +210 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/route.js +10 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/sdk.js +6 -230
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/server.js +45 -4
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/sessions.js +60 -21
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/trail-on-approve.js +103 -0
- package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/ui.js +19 -7
- package/skills/global_config/plan-canvas/scripts/plan-canvas.js +118 -20
- package/skills/global_config/subagent-setup/SKILL.md +6 -0
- package/skills/global_config/subagent-setup/scripts/setup-subagents.mjs +20 -1
- package/skills/playbooks/pb-idea-to-launch/SKILL.md +2 -2
- package/skills/playbooks/pb-redesign-app/SKILL.md +3 -3
- package/skills/playbooks/pb-release-aos/SKILL.md +2 -2
- package/skills/playbooks/pb-ship/SKILL.md +2 -2
- package/skills/playbooks/pb-worktrees-land/SKILL.md +2 -2
- package/skills/global_config/bdb-visual-edit/scripts/pick-snippet.js +0 -27
- package/skills/global_config/visual-edit/README.md +0 -96
- package/skills/global_config/visual-edit/SKILL.md +0 -615
- package/skills/global_config/visual-plan/README.md +0 -93
- package/skills/global_config/visual-plan/SKILL.md +0 -544
- package/skills/global_config/visual-plan/references/canvas.md +0 -139
- package/skills/global_config/visual-plan/references/connection.md +0 -51
- package/skills/global_config/visual-plan/references/document-quality.md +0 -186
- package/skills/global_config/visual-plan/references/exemplar.md +0 -62
- package/skills/global_config/visual-plan/references/local-files.md +0 -99
- package/skills/global_config/visual-plan/references/wireframe.md +0 -319
- package/skills/global_config/visual-recap/README.md +0 -103
- package/skills/global_config/visual-recap/SKILL.md +0 -560
- package/skills/global_config/visual-recap/references/connection.md +0 -51
- package/skills/global_config/visual-recap/references/local-files.md +0 -99
- package/skills/global_config/visual-recap/references/wireframe.md +0 -319
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { roundPoint, clampPoint, fromAnchorUnits, simplify } = require('./geometry');
|
|
4
|
+
|
|
5
|
+
// Inlined into the browser bundle (see index.js): CAPS and the two regexes
|
|
6
|
+
// are emitted as constants, the functions with fn.toString().
|
|
7
|
+
const CAPS = {
|
|
8
|
+
maxItemsPerPost: 20,
|
|
9
|
+
maxBodyBytes: 200000,
|
|
10
|
+
maxShapes: 16,
|
|
11
|
+
maxPointsTotal: 2048,
|
|
12
|
+
maxFreehand: 512,
|
|
13
|
+
text: 4000,
|
|
14
|
+
snippet: 200,
|
|
15
|
+
selector: 500,
|
|
16
|
+
textRange: 1000,
|
|
17
|
+
classes: 12,
|
|
18
|
+
url: 300,
|
|
19
|
+
bbox: 100000
|
|
20
|
+
};
|
|
21
|
+
const SHAPE_COLORS = ['red', 'yellow', 'blue', 'green'];
|
|
22
|
+
const POINT_RULES = { rect: [2, 2], arrow: [2, 2], blur: [2, 2], comment: [1, 1], freehand: [2, 512] };
|
|
23
|
+
const STRIP_RE = /[\u0000-\u001f\u007f-\u009f---]/g;
|
|
24
|
+
const CLASS_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
25
|
+
const SRC_RE = /^[\w@.-]+(?:\/[\w@.-]+)*\.[A-Za-z0-9]{1,8}:\d{1,6}(?::\d{1,5})?$/;
|
|
26
|
+
const TAG_RE = /^[a-z][a-z0-9-]{0,59}$/;
|
|
27
|
+
|
|
28
|
+
function cleanText(value, max) {
|
|
29
|
+
if (typeof value !== 'string') return '';
|
|
30
|
+
return value.replace(STRIP_RE, '').slice(0, max);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cleanClasses(list) {
|
|
34
|
+
const out = [];
|
|
35
|
+
if (!Array.isArray(list)) return out;
|
|
36
|
+
for (let i = 0; i < Math.min(list.length, 200) && out.length < CAPS.classes; i++) {
|
|
37
|
+
const c = list[i];
|
|
38
|
+
if (typeof c === 'string' && CLASS_RE.test(c) && !out.includes(c)) out.push(c);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cleanSrcLoc(value) {
|
|
44
|
+
if (typeof value !== 'string' || value.length > 240 || !SRC_RE.test(value)) return null;
|
|
45
|
+
const file = value.slice(0, value.search(/:\d/));
|
|
46
|
+
return file.split('/').some(s => s.startsWith('.') || s === 'node_modules') ? null : value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeShape(type, points, color) {
|
|
50
|
+
const rule = POINT_RULES[Object.prototype.hasOwnProperty.call(POINT_RULES, type) ? type : ''];
|
|
51
|
+
if (!rule || !Array.isArray(points)) return null;
|
|
52
|
+
if (!points.every(p => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]))) return null;
|
|
53
|
+
const pts = (type === 'freehand' ? simplify(points, 0.002, CAPS.maxFreehand) : points)
|
|
54
|
+
.map(p => roundPoint(clampPoint(p)));
|
|
55
|
+
if (pts.length < rule[0] || pts.length > rule[1]) return null;
|
|
56
|
+
const shape = { type, points: pts };
|
|
57
|
+
if (SHAPE_COLORS.includes(color)) shape.color = color;
|
|
58
|
+
return shape;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createDraft() {
|
|
62
|
+
return { shapes: [], redo: [] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function draftPointCount(draft) {
|
|
66
|
+
return draft.shapes.reduce((n, s) => n + s.points.length, 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Returns '' when added, otherwise the reason it was refused.
|
|
70
|
+
function draftAdd(draft, shape) {
|
|
71
|
+
if (!shape) return 'invalid';
|
|
72
|
+
if (draft.shapes.length >= CAPS.maxShapes) return 'max-shapes';
|
|
73
|
+
if (draftPointCount(draft) + shape.points.length > CAPS.maxPointsTotal) return 'max-points';
|
|
74
|
+
draft.shapes.push(shape);
|
|
75
|
+
draft.redo.length = 0;
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function draftUndo(draft) {
|
|
80
|
+
const s = draft.shapes.pop();
|
|
81
|
+
if (s) draft.redo.push(s);
|
|
82
|
+
return Boolean(s);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function draftRedo(draft) {
|
|
86
|
+
const s = draft.redo.pop();
|
|
87
|
+
if (s) draft.shapes.push(s);
|
|
88
|
+
return Boolean(s);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function draftClear(draft) {
|
|
92
|
+
draft.shapes.length = 0;
|
|
93
|
+
draft.redo.length = 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function shapeSummary(shapes) {
|
|
97
|
+
return Array.isArray(shapes) ? shapes.map(s => s.type).join(', ') : '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function pageBBox(shapes, box) {
|
|
101
|
+
const xs = [];
|
|
102
|
+
const ys = [];
|
|
103
|
+
for (const s of shapes) for (const p of s.points) {
|
|
104
|
+
const pt = fromAnchorUnits(p, box);
|
|
105
|
+
xs.push(pt[0]);
|
|
106
|
+
ys.push(pt[1]);
|
|
107
|
+
}
|
|
108
|
+
if (!xs.length) return null;
|
|
109
|
+
const c = v => Math.round(Math.min(CAPS.bbox, Math.max(0, v)));
|
|
110
|
+
const x = c(Math.min(...xs));
|
|
111
|
+
const y = c(Math.min(...ys));
|
|
112
|
+
return { x, y, w: c(Math.max(...xs)) - x, h: c(Math.max(...ys)) - y };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildItem(input) {
|
|
116
|
+
const text = cleanText(input.text, CAPS.text).trim();
|
|
117
|
+
if (!text || !input.anchor) return null;
|
|
118
|
+
const shapes = (input.shapes || []).slice(0, CAPS.maxShapes);
|
|
119
|
+
const blurred = shapes.some(s => s.type === 'blur');
|
|
120
|
+
const a = input.anchor;
|
|
121
|
+
const tag = a.tag === 'text' || TAG_RE.test(a.tag || '') ? a.tag : 'div';
|
|
122
|
+
const anchor = {
|
|
123
|
+
selector: cleanText(a.selector, CAPS.selector) || 'body',
|
|
124
|
+
tag,
|
|
125
|
+
snippet: blurred ? '' : cleanText(a.snippet, CAPS.snippet)
|
|
126
|
+
};
|
|
127
|
+
const classes = cleanClasses(a.classes);
|
|
128
|
+
if (classes.length) anchor.classes = classes;
|
|
129
|
+
if (a.textRange && !blurred) {
|
|
130
|
+
const t = cleanText(a.textRange.text, CAPS.textRange);
|
|
131
|
+
if (t) anchor.textRange = { text: t };
|
|
132
|
+
}
|
|
133
|
+
const item = { kind: 'annotation', text, anchor };
|
|
134
|
+
let points = 0;
|
|
135
|
+
const kept = [];
|
|
136
|
+
for (const s of shapes) {
|
|
137
|
+
if (points + s.points.length > CAPS.maxPointsTotal) break;
|
|
138
|
+
points += s.points.length;
|
|
139
|
+
const out = { type: s.type, points: s.points.map(p => [p[0], p[1]]) };
|
|
140
|
+
if (SHAPE_COLORS.includes(s.color)) out.color = s.color;
|
|
141
|
+
kept.push(out);
|
|
142
|
+
}
|
|
143
|
+
if (kept.length) item.shapes = kept;
|
|
144
|
+
const v = input.viewport;
|
|
145
|
+
if (v && Number.isFinite(v.w) && Number.isFinite(v.h)) {
|
|
146
|
+
const int = n => Math.min(100000, Math.max(1, Math.round(n)));
|
|
147
|
+
const dpr = Number.isFinite(v.dpr) ? Math.min(8, Math.max(0.5, v.dpr)) : 1;
|
|
148
|
+
item.viewport = { w: int(v.w), h: int(v.h), dpr: Math.round(dpr * 100) / 100 };
|
|
149
|
+
}
|
|
150
|
+
if (input.box) {
|
|
151
|
+
const b = input.box;
|
|
152
|
+
const c = n => (Number.isFinite(n) ? Math.round(Math.min(CAPS.bbox, Math.max(0, n))) : 0);
|
|
153
|
+
const page = kept.length ? pageBBox(kept, b) : { x: c(b.left), y: c(b.top), w: c(b.width), h: c(b.height) };
|
|
154
|
+
if (page) item.page = page;
|
|
155
|
+
}
|
|
156
|
+
if (input.target) {
|
|
157
|
+
const target = { url: cleanText(input.target.url, CAPS.url) };
|
|
158
|
+
const src = cleanSrcLoc(input.target.srcLoc);
|
|
159
|
+
if (src) target.srcLoc = src;
|
|
160
|
+
item.target = target;
|
|
161
|
+
}
|
|
162
|
+
return item;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function chunkItems(items) {
|
|
166
|
+
const chunks = [];
|
|
167
|
+
let cur = [];
|
|
168
|
+
let bytes = 0;
|
|
169
|
+
for (const item of items) {
|
|
170
|
+
const size = JSON.stringify(item).length;
|
|
171
|
+
if (cur.length && (cur.length >= CAPS.maxItemsPerPost || bytes + size > CAPS.maxBodyBytes)) {
|
|
172
|
+
chunks.push(cur);
|
|
173
|
+
cur = [];
|
|
174
|
+
bytes = 0;
|
|
175
|
+
}
|
|
176
|
+
cur.push(item);
|
|
177
|
+
bytes += size;
|
|
178
|
+
}
|
|
179
|
+
if (cur.length) chunks.push(cur);
|
|
180
|
+
return chunks;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const PURE_FUNCTIONS = [
|
|
184
|
+
cleanText, cleanClasses, cleanSrcLoc, makeShape, createDraft, draftPointCount, draftAdd, draftUndo,
|
|
185
|
+
draftRedo, draftClear, shapeSummary, pageBBox, buildItem, chunkItems
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
module.exports = {
|
|
189
|
+
CAPS, SHAPE_COLORS, POINT_RULES, STRIP_RE, CLASS_RE, SRC_RE, TAG_RE, PURE_FUNCTIONS,
|
|
190
|
+
cleanText, cleanClasses, cleanSrcLoc, makeShape, createDraft, draftPointCount, draftAdd, draftUndo,
|
|
191
|
+
draftRedo, draftClear, shapeSummary, pageBBox, buildItem, chunkItems
|
|
192
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Static markup only: nothing here is ever interpolated with page or user data.
|
|
4
|
+
|
|
5
|
+
const TOOLS = [
|
|
6
|
+
['element', 'V', 'Element'],
|
|
7
|
+
['text', 'T', 'Text'],
|
|
8
|
+
['rect', 'R', 'Area'],
|
|
9
|
+
['arrow', 'A', 'Arrow'],
|
|
10
|
+
['freehand', 'F', 'Freehand'],
|
|
11
|
+
['blur', 'B', 'Blur'],
|
|
12
|
+
['comment', 'C', 'Comment']
|
|
13
|
+
];
|
|
14
|
+
const COLORS = ['red', 'yellow', 'blue', 'green'];
|
|
15
|
+
const STROKES = [['2', 'Thin'], ['4', 'Medium'], ['7', 'Thick']];
|
|
16
|
+
|
|
17
|
+
function toolbarCss() {
|
|
18
|
+
return `
|
|
19
|
+
:host{all:initial;--bg:#0a0a0a;--fg:#fff;--mut:#a1a1aa;--line:#2a2a30;--acc:#9b30c4;--acc-fg:#fff;--sh:0 8px 32px rgba(0,0,0,.55)}
|
|
20
|
+
@media (prefers-color-scheme:light){:host{--bg:#fff;--fg:#0a0a0a;--mut:#52525b;--line:#d4d4d8;--sh:0 8px 32px rgba(0,0,0,.18)}}
|
|
21
|
+
*{box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
|
|
22
|
+
.hl{position:fixed;pointer-events:none;border:1.5px solid var(--acc);background:rgba(155,48,196,.12);border-radius:4px;display:none;z-index:2147483646;transition:all .06s ease-out}
|
|
23
|
+
svg.ov{position:absolute;left:0;top:0;overflow:visible;pointer-events:none;z-index:2147483645}
|
|
24
|
+
svg.ov.draw{pointer-events:auto;cursor:crosshair;touch-action:none}
|
|
25
|
+
button{font-size:12px;font-weight:600;line-height:1;cursor:pointer;color:var(--fg);background:transparent;border:1px solid var(--line);border-radius:6px;min-width:32px;min-height:32px;padding:6px 8px}
|
|
26
|
+
button:hover{border-color:var(--acc)}
|
|
27
|
+
button:focus-visible,textarea:focus-visible{outline:2px solid var(--acc);outline-offset:2px}
|
|
28
|
+
button[aria-pressed=true],button.primary{background:var(--acc);border-color:var(--acc);color:var(--acc-fg)}
|
|
29
|
+
button:disabled{opacity:.4;cursor:default}
|
|
30
|
+
.bar{position:fixed;left:50%;bottom:12px;transform:translateX(-50%);z-index:2147483647;display:flex;flex-wrap:wrap;gap:6px;align-items:center;justify-content:center;max-width:calc(100vw - 16px);background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:10px;padding:6px;box-shadow:var(--sh)}
|
|
31
|
+
.bar[hidden],.launch[hidden],.card[hidden],.mini[hidden]{display:none}
|
|
32
|
+
.grp{display:flex;gap:4px;align-items:center}
|
|
33
|
+
.sep{width:1px;align-self:stretch;background:var(--line)}
|
|
34
|
+
.sw{min-width:24px;min-height:24px;width:24px;height:24px;padding:0;border-radius:50%}
|
|
35
|
+
.sw[data-color=red]{background:#e5484d}.sw[data-color=yellow]{background:#f5c518}.sw[data-color=blue]{background:#3b82f6}.sw[data-color=green]{background:#30a46c}
|
|
36
|
+
.sw[aria-pressed=true]{outline:2px solid var(--fg);outline-offset:2px;border-color:transparent}
|
|
37
|
+
.launch{position:fixed;right:12px;bottom:12px;z-index:2147483647;background:var(--bg);box-shadow:var(--sh)}
|
|
38
|
+
.status{flex-basis:100%;text-align:center;font-size:11px;color:var(--mut);min-height:14px}
|
|
39
|
+
.selhint{position:absolute;display:none;z-index:2147483647;background:var(--bg);box-shadow:var(--sh)}
|
|
40
|
+
.card{position:absolute;z-index:2147483647;width:min(300px,calc(100vw - 16px));background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:8px;box-shadow:var(--sh)}
|
|
41
|
+
.card h4{margin:0;padding:10px 12px 0;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--mut)}
|
|
42
|
+
.card .snippet{padding:4px 12px 0;font:10.5px 'SF Mono','Fira Code',monospace;color:var(--acc);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
43
|
+
.card textarea{display:block;width:calc(100% - 24px);margin:8px 12px;min-height:56px;resize:vertical;background:transparent;border:1px solid var(--line);border-radius:6px;color:var(--fg);font-size:16px;line-height:1.4;padding:7px 9px}
|
|
44
|
+
.card .row{display:flex;justify-content:flex-end;gap:8px;padding:0 12px 8px}
|
|
45
|
+
.card .keys{padding:0 12px 10px;font-size:10px;color:var(--mut)}
|
|
46
|
+
.mini{position:fixed;right:12px;bottom:64px;z-index:2147483647;width:min(280px,calc(100vw - 24px));background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:8px;box-shadow:var(--sh);padding:8px;font-size:12px}
|
|
47
|
+
.mini ul{list-style:none;margin:0 0 8px;padding:0;max-height:140px;overflow:auto}
|
|
48
|
+
.mini li{display:flex;gap:6px;align-items:center;padding:3px 0}
|
|
49
|
+
.mini li span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
50
|
+
.mini .msg{margin-top:6px;font-size:11px;color:var(--mut)}
|
|
51
|
+
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toolbarHtml() {
|
|
56
|
+
const tool = ([id, key, label]) =>
|
|
57
|
+
`<button type="button" data-tool="${id}" aria-pressed="false" aria-keyshortcuts="${key}" aria-label="${label} tool (${key})" title="${label} (${key})">${key}</button>`;
|
|
58
|
+
const color = c =>
|
|
59
|
+
`<button type="button" class="sw" data-color="${c}" aria-pressed="false" aria-label="Colour ${c}" title="${c}"></button>`;
|
|
60
|
+
const stroke = ([w, label]) =>
|
|
61
|
+
`<button type="button" data-stroke="${w}" aria-pressed="false" aria-label="${label} stroke" title="${label}">${w}</button>`;
|
|
62
|
+
return `
|
|
63
|
+
<svg class="ov" aria-hidden="true"></svg>
|
|
64
|
+
<div class="hl"></div>
|
|
65
|
+
<button class="selhint" type="button">Annotate selection</button>
|
|
66
|
+
<button class="launch" type="button" aria-label="Annotate this page (Alt+Shift+A)" title="Annotate (Alt+Shift+A)" hidden>Annotate</button>
|
|
67
|
+
<div class="bar" role="toolbar" aria-label="Annotation tools" aria-orientation="horizontal" hidden>
|
|
68
|
+
<div class="grp" role="group" aria-label="Tools">${TOOLS.map(tool).join('')}</div>
|
|
69
|
+
<span class="sep"></span>
|
|
70
|
+
<div class="grp" role="group" aria-label="Colour">${COLORS.map(color).join('')}</div>
|
|
71
|
+
<div class="grp" role="group" aria-label="Stroke">${STROKES.map(stroke).join('')}</div>
|
|
72
|
+
<span class="sep"></span>
|
|
73
|
+
<div class="grp" role="group" aria-label="Edit">
|
|
74
|
+
<button type="button" data-act="undo" aria-label="Undo (Ctrl or Cmd+Z)" title="Undo">↶</button>
|
|
75
|
+
<button type="button" data-act="redo" aria-label="Redo (Ctrl or Cmd+Shift+Z)" title="Redo">↷</button>
|
|
76
|
+
<button type="button" data-act="delete" aria-label="Delete drawing (Delete)" title="Delete">✕</button>
|
|
77
|
+
<button type="button" data-act="close" aria-label="Close annotation tools (Esc)" title="Close">Esc</button>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="status" role="status" aria-live="polite"></div>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="card" role="dialog" aria-label="Annotation note" hidden>
|
|
82
|
+
<h4></h4>
|
|
83
|
+
<div class="snippet"></div>
|
|
84
|
+
<textarea aria-label="What should change here?" placeholder="What should change here?"></textarea>
|
|
85
|
+
<div class="row">
|
|
86
|
+
<button type="button" data-act="cancel">Cancel</button>
|
|
87
|
+
<button type="button" data-act="queue" class="primary">Queue</button>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="keys">Enter to queue · Cmd/Ctrl+Enter to queue & send · Esc to cancel</div>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="mini" role="region" aria-label="Queued annotations" hidden>
|
|
92
|
+
<ul></ul>
|
|
93
|
+
<button type="button" data-act="send" class="primary">Send</button>
|
|
94
|
+
<div class="msg" role="status" aria-live="polite"></div>
|
|
95
|
+
</div>
|
|
96
|
+
`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { toolbarCss, toolbarHtml, TOOLS, COLORS, STROKES };
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Annotation endpoint for a running dev app (the "annotate" half of Plan
|
|
5
|
+
* Canvas). server.js hands requests for `/annotate.js` and `/api/annotate/*`
|
|
6
|
+
* here after the Host check and before the Origin/fetch-site gate, because the
|
|
7
|
+
* caller is a page on a different loopback origin by design.
|
|
8
|
+
*
|
|
9
|
+
* Trust model: a per-session token (only its SHA-256 is stored) bound to one
|
|
10
|
+
* exact loopback app origin. The endpoint can queue `kind:"annotation"` items
|
|
11
|
+
* and nothing else, so an app page can never approve, chat or end a session.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const crypto = require('crypto');
|
|
15
|
+
|
|
16
|
+
const { normalizeOrigin } = require('./annotation-schema');
|
|
17
|
+
|
|
18
|
+
const KEY_RE = /^[a-f0-9]{12}$/;
|
|
19
|
+
const ROUTE_RE = /^\/api\/annotate\/([a-f0-9]{12})(\/token)?$/;
|
|
20
|
+
const MAX_BODY_BYTES = 256 * 1024;
|
|
21
|
+
const MAX_TOKEN_BODY_BYTES = 4 * 1024;
|
|
22
|
+
const MAX_ITEMS_PER_POST = 20;
|
|
23
|
+
const MAX_PENDING = 200;
|
|
24
|
+
const RATE_PER_MINUTE = 60;
|
|
25
|
+
const DEFAULT_TTL_MS = 8 * 60 * 60 * 1000;
|
|
26
|
+
const MAX_TTL_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
const MIN_TTL_MS = 1000;
|
|
28
|
+
const MAX_TOKEN_HEADER = 200;
|
|
29
|
+
const SERVER_HOST = '127.0.0.1';
|
|
30
|
+
|
|
31
|
+
const STUB_CLIENT_JS =
|
|
32
|
+
"'use strict';\n(() => { try { console.warn('[aos-annotate] client layer not installed in this build'); } catch (_) {} })();\n";
|
|
33
|
+
|
|
34
|
+
function sha256(value) {
|
|
35
|
+
return crypto.createHash('sha256').update(value).digest();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadClientJs(version) {
|
|
39
|
+
let mod;
|
|
40
|
+
try {
|
|
41
|
+
mod = require('./annotate-client');
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error && error.code === 'MODULE_NOT_FOUND') return STUB_CLIENT_JS;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
return mod.annotateClientJs({ transport: 'fetch', version });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sendJson(res, status, payload, extraHeaders = {}) {
|
|
50
|
+
res.writeHead(status, {
|
|
51
|
+
'content-type': 'application/json; charset=utf-8',
|
|
52
|
+
'cache-control': 'no-store',
|
|
53
|
+
'x-content-type-options': 'nosniff',
|
|
54
|
+
...extraHeaders
|
|
55
|
+
});
|
|
56
|
+
res.end(JSON.stringify(payload));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class HttpError extends Error {
|
|
60
|
+
constructor(status, code, message, headers) {
|
|
61
|
+
super(message || code);
|
|
62
|
+
this.status = status;
|
|
63
|
+
this.code = code;
|
|
64
|
+
this.headers = headers;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Stops reading at the cap; the caller answers 413 and drops the socket.
|
|
69
|
+
function readBody(req, maxBytes) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const declared = Number(req.headers['content-length']);
|
|
72
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
73
|
+
return reject(new HttpError(413, 'too_large', 'body too large', { connection: 'close' }));
|
|
74
|
+
}
|
|
75
|
+
let size = 0;
|
|
76
|
+
const chunks = [];
|
|
77
|
+
req.on('data', chunk => {
|
|
78
|
+
size += chunk.length;
|
|
79
|
+
if (size > maxBytes) {
|
|
80
|
+
chunks.length = 0;
|
|
81
|
+
reject(new HttpError(413, 'too_large', 'body too large', { connection: 'close' }));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
chunks.push(chunk);
|
|
85
|
+
});
|
|
86
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
87
|
+
req.on('error', reject);
|
|
88
|
+
return undefined;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseJsonObject(text) {
|
|
93
|
+
if (!text) return {};
|
|
94
|
+
try {
|
|
95
|
+
const value = JSON.parse(text);
|
|
96
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isJsonContentType(value) {
|
|
103
|
+
return typeof value === 'string' && /^application\/json\s*(;|$)/i.test(value.trim());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createAnnotateHandler({ store, version = '0.0.0', onQueued = () => {}, now = Date.now, log = () => {} } = {}) {
|
|
107
|
+
if (!store) throw new Error('createAnnotateHandler requires a session store');
|
|
108
|
+
const buckets = new Map(); // key -> { tokens, at }
|
|
109
|
+
|
|
110
|
+
function takeRate(key) {
|
|
111
|
+
const t = now();
|
|
112
|
+
const bucket = buckets.get(key) || { tokens: RATE_PER_MINUTE, at: t };
|
|
113
|
+
bucket.tokens = Math.min(RATE_PER_MINUTE, bucket.tokens + Math.max(0, t - bucket.at) * (RATE_PER_MINUTE / 60000));
|
|
114
|
+
bucket.at = t;
|
|
115
|
+
buckets.set(key, bucket);
|
|
116
|
+
if (bucket.tokens < 1) return Math.max(1, Math.ceil((1 - bucket.tokens) / (RATE_PER_MINUTE / 60)));
|
|
117
|
+
bucket.tokens -= 1;
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function corsHeaders(origin, { preflight = false, privateNetwork = false } = {}) {
|
|
122
|
+
const headers = { 'access-control-allow-origin': origin, vary: 'Origin' };
|
|
123
|
+
if (preflight) {
|
|
124
|
+
headers['access-control-allow-methods'] = 'POST';
|
|
125
|
+
headers['access-control-allow-headers'] = 'content-type, x-aos-annotate-token';
|
|
126
|
+
headers['access-control-max-age'] = '600';
|
|
127
|
+
if (privateNetwork) headers['access-control-allow-private-network'] = 'true';
|
|
128
|
+
}
|
|
129
|
+
return headers;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function issueToken(key, origin, ttlMs = DEFAULT_TTL_MS) {
|
|
133
|
+
const bound = normalizeOrigin(origin);
|
|
134
|
+
if (!bound) throw new HttpError(400, 'bad_origin', 'origin must be http://127.0.0.1|localhost|[::1]:<1024-65535>');
|
|
135
|
+
const session = store.get(key);
|
|
136
|
+
if (!session) throw new HttpError(404, 'unknown_session', 'unknown session');
|
|
137
|
+
if (session.status === 'ended') throw new HttpError(409, 'ended', 'session already ended');
|
|
138
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
139
|
+
const createdAt = now();
|
|
140
|
+
const expiresAt = createdAt + Math.min(MAX_TTL_MS, Math.max(MIN_TTL_MS, ttlMs));
|
|
141
|
+
store.setAnnotateToken(key, { tokenSha256: sha256(token).toString('hex'), origin: bound, createdAt, expiresAt });
|
|
142
|
+
buckets.delete(key);
|
|
143
|
+
return { token, origin: bound, expiresAt };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function serveScript(req, res) {
|
|
147
|
+
if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed', code: 'method' }, { allow: 'GET' });
|
|
148
|
+
res.writeHead(200, {
|
|
149
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
150
|
+
'cache-control': 'no-cache',
|
|
151
|
+
'x-content-type-options': 'nosniff',
|
|
152
|
+
'cross-origin-resource-policy': 'cross-origin',
|
|
153
|
+
'x-aos-annotate-version': version
|
|
154
|
+
});
|
|
155
|
+
return res.end(loadClientJs(version));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function handleToken(req, res, key) {
|
|
159
|
+
if (req.method !== 'POST') throw new HttpError(405, 'method', 'method not allowed', { allow: 'POST' });
|
|
160
|
+
// Browsers always send Origin or Sec-Fetch-Site; only the CLI sends neither.
|
|
161
|
+
if (req.headers.origin !== undefined || req.headers['sec-fetch-site'] !== undefined) {
|
|
162
|
+
throw new HttpError(403, 'browser_forbidden', 'token issuing is CLI-only');
|
|
163
|
+
}
|
|
164
|
+
const body = parseJsonObject(await readBody(req, MAX_TOKEN_BODY_BYTES));
|
|
165
|
+
if (!body) throw new HttpError(400, 'json', 'invalid JSON body');
|
|
166
|
+
let ttlMs = DEFAULT_TTL_MS;
|
|
167
|
+
if (body.ttlMs !== undefined) {
|
|
168
|
+
if (typeof body.ttlMs !== 'number' || !Number.isFinite(body.ttlMs) || body.ttlMs < MIN_TTL_MS) {
|
|
169
|
+
throw new HttpError(400, 'bad_ttl', 'ttlMs must be a number of at least 1000');
|
|
170
|
+
}
|
|
171
|
+
ttlMs = body.ttlMs;
|
|
172
|
+
}
|
|
173
|
+
const issued = issueToken(key, body.origin, ttlMs);
|
|
174
|
+
const base = `http://${SERVER_HOST}:${req.socket.localPort}`;
|
|
175
|
+
const src = `${base}/annotate.js?v=${encodeURIComponent(version)}`;
|
|
176
|
+
const scriptTag = `<script src="${src}" data-session="${key}" data-token="${issued.token}"></script>`;
|
|
177
|
+
const bookmarklet =
|
|
178
|
+
`javascript:(function(){var s=document.createElement('script');s.src='${src}';` +
|
|
179
|
+
`s.setAttribute('data-session','${key}');s.setAttribute('data-token','${issued.token}');` +
|
|
180
|
+
`document.documentElement.appendChild(s)})()`;
|
|
181
|
+
return sendJson(res, 200, {
|
|
182
|
+
token: issued.token,
|
|
183
|
+
origin: issued.origin,
|
|
184
|
+
expiresAt: new Date(issued.expiresAt).toISOString(),
|
|
185
|
+
scriptTag,
|
|
186
|
+
bookmarklet
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function handlePreflight(req, res, key) {
|
|
191
|
+
const record = store.getAnnotateToken(key);
|
|
192
|
+
const origin = req.headers.origin;
|
|
193
|
+
// An expired token still gets the preflight so the POST can answer 401 token_expired readably.
|
|
194
|
+
if (!record || typeof origin !== 'string' || origin !== record.origin) {
|
|
195
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
196
|
+
return res.end(JSON.stringify({ error: 'forbidden', code: 'origin' }));
|
|
197
|
+
}
|
|
198
|
+
const privateNetwork = req.headers['access-control-request-private-network'] === 'true';
|
|
199
|
+
res.writeHead(204, corsHeaders(origin, { preflight: true, privateNetwork }));
|
|
200
|
+
return res.end();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function handlePost(req, res, key) {
|
|
204
|
+
const session = store.get(key);
|
|
205
|
+
if (!session) throw new HttpError(404, 'unknown_session', 'unknown session');
|
|
206
|
+
const record = store.getAnnotateToken(key);
|
|
207
|
+
// The bound origin may read every answer (including 401 token errors) so the
|
|
208
|
+
// page can show "link expired"; any other origin gets no CORS headers at all.
|
|
209
|
+
const cors = record && req.headers.origin === record.origin ? corsHeaders(record.origin) : {};
|
|
210
|
+
try {
|
|
211
|
+
const header = req.headers['x-aos-annotate-token'];
|
|
212
|
+
if (typeof header !== 'string' || header.length === 0) throw new HttpError(401, 'no_token', 'token required');
|
|
213
|
+
// No record means no bound origin to compare, so this stays 401 rather than 403.
|
|
214
|
+
if (!record) throw new HttpError(401, 'token', 'invalid token');
|
|
215
|
+
if (now() >= record.expiresAt) throw new HttpError(401, 'token_expired', 'token expired');
|
|
216
|
+
if (req.headers.origin !== record.origin) throw new HttpError(403, 'origin', 'origin not allowed');
|
|
217
|
+
if (header.length > MAX_TOKEN_HEADER) throw new HttpError(401, 'token', 'invalid token');
|
|
218
|
+
const expected = Buffer.from(String(record.tokenSha256), 'hex');
|
|
219
|
+
const presented = sha256(header);
|
|
220
|
+
if (expected.length !== presented.length || !crypto.timingSafeEqual(expected, presented)) {
|
|
221
|
+
throw new HttpError(401, 'token', 'invalid token');
|
|
222
|
+
}
|
|
223
|
+
const retryAfter = takeRate(key);
|
|
224
|
+
if (retryAfter) throw new HttpError(429, 'rate', 'too many requests', { 'retry-after': String(retryAfter) });
|
|
225
|
+
if (!isJsonContentType(req.headers['content-type'])) {
|
|
226
|
+
throw new HttpError(415, 'content_type', 'content-type must be application/json');
|
|
227
|
+
}
|
|
228
|
+
const body = parseJsonObject(await readBody(req, MAX_BODY_BYTES));
|
|
229
|
+
if (!body) throw new HttpError(400, 'json', 'invalid JSON body');
|
|
230
|
+
const items = Object.hasOwn(body, 'items') ? body.items : undefined;
|
|
231
|
+
if (!Array.isArray(items) || items.length < 1 || items.length > MAX_ITEMS_PER_POST) {
|
|
232
|
+
throw new HttpError(400, 'items', `items must be an array of 1-${MAX_ITEMS_PER_POST}`);
|
|
233
|
+
}
|
|
234
|
+
if (session.status === 'ended') throw new HttpError(409, 'ended', 'session already ended');
|
|
235
|
+
if (session.pendingFeedback.length + items.length > MAX_PENDING) {
|
|
236
|
+
throw new HttpError(429, 'queue_full', 'too many pending annotations; the agent has not picked them up');
|
|
237
|
+
}
|
|
238
|
+
const result = store.queueFeedback(key, items, { origin: 'app', boundOrigin: record.origin });
|
|
239
|
+
if (!result) throw new HttpError(409, 'ended', 'session already ended');
|
|
240
|
+
onQueued(key);
|
|
241
|
+
return sendJson(
|
|
242
|
+
res,
|
|
243
|
+
200,
|
|
244
|
+
{ status: 'queued', accepted: result.accepted.length, rejected: result.rejected, pending: result.pending },
|
|
245
|
+
cors
|
|
246
|
+
);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (error instanceof HttpError) error.headers = { ...cors, ...error.headers };
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function handles(pathname) {
|
|
254
|
+
return pathname === '/annotate.js' || (typeof pathname === 'string' && pathname.startsWith('/api/annotate/'));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function handle(req, res, url) {
|
|
258
|
+
try {
|
|
259
|
+
if (url.pathname === '/annotate.js') return serveScript(req, res);
|
|
260
|
+
const match = ROUTE_RE.exec(url.pathname);
|
|
261
|
+
if (!match || !KEY_RE.test(match[1])) throw new HttpError(404, 'not_found', 'not found');
|
|
262
|
+
const [, key, tokenSuffix] = match;
|
|
263
|
+
if (tokenSuffix) return await handleToken(req, res, key);
|
|
264
|
+
if (req.method === 'OPTIONS') return handlePreflight(req, res, key);
|
|
265
|
+
if (req.method === 'POST') return await handlePost(req, res, key);
|
|
266
|
+
throw new HttpError(405, 'method', 'method not allowed', { allow: 'POST, OPTIONS' });
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (res.headersSent) return res.end();
|
|
269
|
+
if (error instanceof HttpError) {
|
|
270
|
+
sendJson(res, error.status, { error: error.message, code: error.code }, error.headers);
|
|
271
|
+
if (error.status === 413) res.once('finish', () => req.destroy());
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
log(`[plan-canvas] annotate error: ${error && error.message}`);
|
|
275
|
+
return sendJson(res, 500, { error: 'internal error', code: 'internal' });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { handles, handle, issueToken };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
module.exports = { MAX_BODY_BYTES, MAX_ITEMS_PER_POST, MAX_PENDING, RATE_PER_MINUTE, createAnnotateHandler };
|