pi2web 0.2.0 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +209 -18
  2. package/dist/activity.js +178 -0
  3. package/dist/agent-feed.js +133 -0
  4. package/dist/agent-templates.js +144 -0
  5. package/dist/agents.js +575 -97
  6. package/dist/auth.js +85 -20
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +221 -0
  9. package/dist/collab/hub.js +2888 -0
  10. package/dist/collab/pi-extension.js +494 -0
  11. package/dist/collab/review-flow.js +459 -0
  12. package/dist/collab/routes.js +285 -0
  13. package/dist/collab/schemas.js +281 -0
  14. package/dist/collab/scoring-flow.js +336 -0
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +918 -0
  17. package/dist/collab/types.js +137 -0
  18. package/dist/collab/validate.js +185 -0
  19. package/dist/drafts.js +50 -0
  20. package/dist/http/agent-routes.js +196 -0
  21. package/dist/http/login-limiter.js +53 -0
  22. package/dist/http/mail-settings-routes.js +53 -0
  23. package/dist/http/system-prompt-routes.js +52 -0
  24. package/dist/http/utils.js +43 -0
  25. package/dist/http/web-assets.js +63 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +95 -21
  28. package/dist/main.js +141 -17
  29. package/dist/mcp.js +132 -0
  30. package/dist/metadata-store.js +199 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +288 -49
  33. package/dist/server.js +558 -358
  34. package/dist/session-namer.js +163 -0
  35. package/dist/terminals.js +94 -27
  36. package/dist/timeline.js +185 -0
  37. package/dist/voice.js +196 -40
  38. package/dist/workspaces.js +91 -26
  39. package/dist/ws/agent-socket.js +256 -0
  40. package/dist/ws/terminal-socket.js +46 -0
  41. package/npm-shrinkwrap.json +3685 -0
  42. package/package.json +18 -6
  43. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
  45. package/packages/protocol/openapi.yaml +8 -2
  46. package/packages/protocol/websocket.schema.json +94 -10
  47. package/web/activity-view.js +121 -0
  48. package/web/app.js +3770 -404
  49. package/web/backend-state.js +269 -0
  50. package/web/collab.html +95 -0
  51. package/web/collab.js +1080 -0
  52. package/web/hid-keyboard.js +453 -0
  53. package/web/index.html +14 -9
  54. package/web/styles.css +73 -0
  55. package/ANDROID_APP_FEATURES.md +0 -256
@@ -0,0 +1,137 @@
1
+ /** Shared contracts for the multi-agent collaboration hub. Everything crossing the HTTP boundary is defined here. */
2
+ export const REVIEW_PHASES = [
3
+ 'draft',
4
+ 'implementing',
5
+ 'collecting',
6
+ 'consolidating',
7
+ 'validating',
8
+ 'merge_voting',
9
+ 'issue_discussing',
10
+ 'issue_reconsidering',
11
+ 'awaiting_human',
12
+ 'finished'
13
+ ];
14
+ export const SCORING_PHASES = [
15
+ 'nominating',
16
+ 'consolidating',
17
+ 'voting',
18
+ 'rubric_locked',
19
+ 'scoring',
20
+ 'analysis',
21
+ 'debating',
22
+ 'rescoring',
23
+ 'awaiting_human',
24
+ 'finalized'
25
+ ];
26
+ export const ROLES = ['implementer', 'reviewer', 'moderator', 'human'];
27
+ /**
28
+ * Roles a *seat* may be registered with. `human` is deliberately excluded: a human acts with the pairing
29
+ * code, and a `human`-role participant token would hold nominate/vote/score capabilities while
30
+ * `scoringPanel()` never waits for it — its submissions would move a tally nobody is waiting on.
31
+ */
32
+ export const REGISTRABLE_ROLES = ['implementer', 'reviewer'];
33
+ export const CAPABILITIES = {
34
+ implementer: ['file_finding', 'ready', 'withdraw', 'clarify', 'escalate'],
35
+ reviewer: [
36
+ 'file_finding',
37
+ 'withdraw',
38
+ 'nominate',
39
+ 'vote',
40
+ 'score',
41
+ 'debate',
42
+ 'clarify',
43
+ 'merge',
44
+ 'escalate'
45
+ ],
46
+ moderator: ['file_finding', 'withdraw', 'merge', 'clarify', 'escalate'],
47
+ human: [
48
+ 'file_finding',
49
+ 'ready',
50
+ 'withdraw',
51
+ 'nominate',
52
+ 'vote',
53
+ 'score',
54
+ 'debate',
55
+ 'clarify',
56
+ 'merge',
57
+ 'escalate'
58
+ ]
59
+ };
60
+ export const can = (role, capability) => CAPABILITIES[role]?.includes(capability) ?? false;
61
+ export const SEVERITIES = ['blocker', 'critical', 'major', 'minor', 'nit'];
62
+ export const CATEGORIES = [
63
+ 'security',
64
+ 'correctness',
65
+ 'performance',
66
+ 'maintainability',
67
+ 'style',
68
+ 'test',
69
+ 'docs',
70
+ 'process'
71
+ ];
72
+ export const REQUIRED_ACTIONS = ['must_fix', 'should_fix', 'discuss', 'fyi'];
73
+ export const ISSUE_STATUSES = [
74
+ 'open',
75
+ 'confirmed',
76
+ 'answered',
77
+ 'resolved',
78
+ 'escalated',
79
+ 'human_ruled',
80
+ 'wontfix',
81
+ 'closed',
82
+ 'duplicate',
83
+ 'withdrawn'
84
+ ];
85
+ /** Statuses that still block the session from finishing. */
86
+ export const OPEN_ISSUE_STATUSES = ['open', 'answered', 'escalated'];
87
+ export const ESCALATION_KINDS = [
88
+ 'issue_dispute',
89
+ 'rubric_dispute',
90
+ 'score_dispute',
91
+ 'budget_exhausted',
92
+ 'other'
93
+ ];
94
+ export const VOTE_STANCES = ['approve', 'reject', 'abstain'];
95
+ export const DEBATE_STANCES = ['raise', 'lower', 'hold', 'clarify'];
96
+ /** approvalThreshold is "two thirds" with a little headroom, so an exact 2-of-3 vote passes. */
97
+ export const DEFAULT_POLICY = {
98
+ maxIssueRounds: 3,
99
+ maxTotalRounds: 6,
100
+ overdueWarningSec: 1800,
101
+ autoEscalateOnDeadlock: true,
102
+ blindFindings: true,
103
+ consensusReview: false,
104
+ maxConsensusRounds: 3,
105
+ implementationFirst: false,
106
+ tokenBudgetPerParticipant: 600_000,
107
+ scoring: {
108
+ minCriteria: 4,
109
+ maxCriteria: 8,
110
+ approvalThreshold: 0.66,
111
+ maxVotingRounds: 3,
112
+ scale: { min: 0, max: 10, step: 0.5 },
113
+ convergenceRange: 2,
114
+ maxDebateRounds: 2,
115
+ blindScoring: true
116
+ }
117
+ };
118
+ /** Error codes returned to agents. Keep them stable: agents branch on these strings. */
119
+ export const COLLAB_ERRORS = {
120
+ validationFailed: 'VALIDATION_FAILED',
121
+ evidenceRequired: 'EVIDENCE_REQUIRED',
122
+ sessionNotFound: 'COLLAB_SESSION_NOT_FOUND',
123
+ participantNotFound: 'COLLAB_PARTICIPANT_NOT_FOUND',
124
+ issueNotFound: 'COLLAB_ISSUE_NOT_FOUND',
125
+ escalationNotFound: 'COLLAB_ESCALATION_NOT_FOUND',
126
+ forbidden: 'COLLAB_FORBIDDEN',
127
+ humanOnly: 'COLLAB_HUMAN_ONLY',
128
+ wrongPhase: 'COLLAB_WRONG_PHASE',
129
+ conflict: 'COLLAB_CONFLICT',
130
+ staleBaseline: 'STALE_BASELINE',
131
+ noCodeChange: 'NO_CODE_CHANGE',
132
+ budgetExhausted: 'TOKEN_BUDGET_EXHAUSTED',
133
+ humanRulingFinal: 'HUMAN_RULING_FINAL',
134
+ scoresSealed: 'SCORES_SEALED',
135
+ criterionNotFound: 'COLLAB_CRITERION_NOT_FOUND',
136
+ debateNotFound: 'COLLAB_DEBATE_NOT_FOUND'
137
+ };
@@ -0,0 +1,185 @@
1
+ export class ValidationError extends Error {
2
+ fieldErrors;
3
+ code = 'VALIDATION_FAILED';
4
+ constructor(fieldErrors) {
5
+ super(`Invalid request: ${fieldErrors.map(e => `${e.path} ${e.code}`).join(', ')}`);
6
+ this.fieldErrors = fieldErrors;
7
+ }
8
+ }
9
+ const fail = (ctx, code, message, expected) => {
10
+ ctx.errors.push({ path: ctx.path || '(root)', code, message, ...(expected ? { expected } : {}) });
11
+ };
12
+ const child = (ctx, key) => ({
13
+ path: typeof key === 'number' ? `${ctx.path}[${key}]` : ctx.path ? `${ctx.path}.${key}` : key,
14
+ errors: ctx.errors
15
+ });
16
+ export const str = (options = {}) => ({
17
+ parse(value, ctx) {
18
+ if (typeof value !== 'string') {
19
+ fail(ctx, 'NOT_A_STRING', 'Expected a string', 'string');
20
+ return '';
21
+ }
22
+ const text = options.trim === false ? value : value.trim();
23
+ const min = options.min ?? 1, max = options.max ?? 4000;
24
+ if (text.length < min)
25
+ fail(ctx, 'TOO_SHORT', `Expected at least ${min} characters, received ${text.length}`, `length >= ${min}`);
26
+ if (text.length > max)
27
+ fail(ctx, 'TOO_LONG', `Expected at most ${max} characters, received ${text.length}`, `length <= ${max}`);
28
+ if (options.pattern && !options.pattern.test(text))
29
+ fail(ctx, 'PATTERN_MISMATCH', `Expected a value matching ${options.pattern}`, String(options.pattern));
30
+ return text;
31
+ }
32
+ });
33
+ export const num = (options = {}) => ({
34
+ parse(value, ctx) {
35
+ const parsed = typeof value === 'number'
36
+ ? value
37
+ : typeof value === 'string' && value.trim() !== ''
38
+ ? Number(value)
39
+ : NaN;
40
+ if (!Number.isFinite(parsed)) {
41
+ fail(ctx, 'NOT_A_NUMBER', 'Expected a finite number', 'number');
42
+ return 0;
43
+ }
44
+ if (options.integer && !Number.isInteger(parsed))
45
+ fail(ctx, 'NOT_AN_INTEGER', 'Expected an integer', 'integer');
46
+ if (options.min !== undefined && parsed < options.min)
47
+ fail(ctx, 'TOO_SMALL', `Expected a value >= ${options.min}`, `>= ${options.min}`);
48
+ if (options.max !== undefined && parsed > options.max)
49
+ fail(ctx, 'TOO_LARGE', `Expected a value <= ${options.max}`, `<= ${options.max}`);
50
+ // Floating point: 6.5 / 0.5 must be treated as an exact multiple, so compare against a rounded quotient.
51
+ if (options.step) {
52
+ const quotient = parsed / options.step;
53
+ if (Math.abs(quotient - Math.round(quotient)) > 1e-9)
54
+ fail(ctx, 'NOT_A_MULTIPLE', `Expected a multiple of ${options.step}`, `multiple of ${options.step}`);
55
+ }
56
+ return parsed;
57
+ }
58
+ });
59
+ export const bool = () => ({
60
+ parse(value, ctx) {
61
+ if (typeof value !== 'boolean') {
62
+ fail(ctx, 'NOT_A_BOOLEAN', 'Expected true or false', 'boolean');
63
+ return false;
64
+ }
65
+ return value;
66
+ }
67
+ });
68
+ export const oneOf = (values) => ({
69
+ parse(value, ctx) {
70
+ if (typeof value !== 'string' || !values.includes(value)) {
71
+ fail(ctx, 'NOT_ALLOWED', `Expected one of: ${values.join(', ')}`, values.join('|'));
72
+ return values[0];
73
+ }
74
+ return value;
75
+ }
76
+ });
77
+ export const arr = (item, options = {}) => ({
78
+ parse(value, ctx) {
79
+ if (!Array.isArray(value)) {
80
+ fail(ctx, 'NOT_AN_ARRAY', 'Expected an array', 'array');
81
+ return [];
82
+ }
83
+ const min = options.min ?? 0, max = options.max ?? 200;
84
+ if (value.length < min)
85
+ fail(ctx, 'TOO_FEW_ITEMS', `Expected at least ${min} items, received ${value.length}`, `length >= ${min}`);
86
+ if (value.length > max) {
87
+ fail(ctx, 'TOO_MANY_ITEMS', `Expected at most ${max} items, received ${value.length}`, `length <= ${max}`);
88
+ return [];
89
+ }
90
+ return value.map((entry, index) => item.parse(entry, child(ctx, index)));
91
+ }
92
+ });
93
+ /**
94
+ * A map whose keys are data, not schema - rubric anchors are `{"0":"...","10":"..."}`. `obj()` cannot express
95
+ * this: it only ever copies the fields its shape declares, so an empty shape silently parses to `{}`.
96
+ */
97
+ export const record = (value, limits = {}) => ({
98
+ parse(input, ctx) {
99
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
100
+ fail(ctx, 'NOT_AN_OBJECT', 'Expected an object', 'object');
101
+ return {};
102
+ }
103
+ const source = input, keys = Object.keys(source), result = {};
104
+ if (limits.maxKeys !== undefined && keys.length > limits.maxKeys)
105
+ fail(ctx, 'TOO_LONG', `At most ${limits.maxKeys} entries are accepted`, `<= ${limits.maxKeys} entries`);
106
+ for (const key of keys) {
107
+ if (limits.maxKeyLength !== undefined && key.length > limits.maxKeyLength)
108
+ fail(child(ctx, key), 'TOO_LONG', `A key may be at most ${limits.maxKeyLength} characters`);
109
+ result[key] = value.parse(source[key], child(ctx, key));
110
+ }
111
+ return result;
112
+ }
113
+ });
114
+ export const obj = (shape) => ({
115
+ parse(value, ctx) {
116
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
117
+ fail(ctx, 'NOT_AN_OBJECT', 'Expected an object', 'object');
118
+ return {};
119
+ }
120
+ const source = value, result = {};
121
+ for (const [key, validator] of Object.entries(shape)) {
122
+ const entry = source[key];
123
+ if (entry === undefined || entry === null) {
124
+ if (validator.optional) {
125
+ if (validator.fallback)
126
+ result[key] = validator.fallback();
127
+ continue;
128
+ }
129
+ fail(child(ctx, key), 'REQUIRED', 'This field is required');
130
+ continue;
131
+ }
132
+ result[key] = validator.parse(entry, child(ctx, key));
133
+ }
134
+ for (const key of Object.keys(source))
135
+ if (!(key in shape))
136
+ fail(child(ctx, key), 'UNKNOWN_FIELD', `Unknown field. Allowed fields: ${Object.keys(shape).join(', ')}`);
137
+ return result;
138
+ }
139
+ });
140
+ /**
141
+ * Cross-field rule on top of a structurally valid value. Field-by-field checks cannot catch
142
+ * `{min:10,max:1}`: every field is in range, yet the combination is unusable.
143
+ * The check only runs when the inner validator produced no errors, so it never reports on garbage.
144
+ */
145
+ export const refine = (inner, check) => ({
146
+ parse(value, ctx) {
147
+ const before = ctx.errors.length, result = inner.parse(value, ctx);
148
+ if (ctx.errors.length > before)
149
+ return result;
150
+ for (const error of check(result) ?? [])
151
+ ctx.errors.push({ ...error, path: ctx.path ? `${ctx.path}.${error.path}` : error.path });
152
+ return result;
153
+ }
154
+ });
155
+ export const optional = (inner) => ({
156
+ parse: (value, ctx) => inner.parse(value, ctx),
157
+ optional: true
158
+ });
159
+ export const withDefault = (inner, fallback) => ({
160
+ parse: (value, ctx) => inner.parse(value, ctx),
161
+ optional: true,
162
+ fallback
163
+ });
164
+ /** Accepts any JSON value. Used for opaque payloads the hub stores but never interprets. */
165
+ export const anyJson = (maxBytes = 64 * 1024) => ({
166
+ parse(value, ctx) {
167
+ let size = 0;
168
+ try {
169
+ size = JSON.stringify(value)?.length ?? 0;
170
+ }
171
+ catch {
172
+ fail(ctx, 'NOT_SERIALIZABLE', 'Expected JSON-serializable data');
173
+ return null;
174
+ }
175
+ if (size > maxBytes)
176
+ fail(ctx, 'TOO_LARGE', `Expected at most ${maxBytes} serialized bytes, received ${size}`);
177
+ return value;
178
+ }
179
+ });
180
+ export function parse(validator, value) {
181
+ const errors = [], result = validator.parse(value, { path: '', errors });
182
+ if (errors.length)
183
+ throw new ValidationError(errors.slice(0, 50));
184
+ return result;
185
+ }
package/dist/drafts.js ADDED
@@ -0,0 +1,50 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export class DraftHub {
3
+ pending = [];
4
+ listeners = new Set();
5
+ max = 50;
6
+ ttlMs = 60 * 60_000;
7
+ /** Returns the stored draft and how many Web UIs were reached right now. */
8
+ add(input) {
9
+ this.expire();
10
+ const draft = {
11
+ id: `draft-${randomUUID()}`,
12
+ text: input.text,
13
+ mode: input.mode ?? 'append',
14
+ source: input.source,
15
+ createdAt: new Date().toISOString()
16
+ };
17
+ this.pending.push(draft);
18
+ while (this.pending.length > this.max)
19
+ this.pending.shift();
20
+ const delivered = this.listeners.size;
21
+ for (const listener of [...this.listeners])
22
+ listener(draft, false);
23
+ return { draft, delivered };
24
+ }
25
+ /** First acknowledgement wins: other tabs that already received it simply keep their copy. */
26
+ ack(id) {
27
+ const before = this.pending.length;
28
+ this.pending = this.pending.filter(d => d.id !== id);
29
+ return this.pending.length < before;
30
+ }
31
+ list() {
32
+ this.expire();
33
+ return [...this.pending];
34
+ }
35
+ subscriberCount() {
36
+ return this.listeners.size;
37
+ }
38
+ subscribe(listener) {
39
+ this.listeners.add(listener);
40
+ for (const draft of this.list())
41
+ listener(draft, true);
42
+ return () => {
43
+ this.listeners.delete(listener);
44
+ };
45
+ }
46
+ expire() {
47
+ const cutoff = Date.now() - this.ttlMs;
48
+ this.pending = this.pending.filter(d => Date.parse(d.createdAt) >= cutoff);
49
+ }
50
+ }
@@ -0,0 +1,196 @@
1
+ import { listMcpServers, setMcpServerEnabled } from '../mcp.js';
2
+ import { json } from './utils.js';
3
+ /** Handles `/api/v1/agents/**`. Returns undefined when the request is not an Agent route. */
4
+ export async function handleAgentRoutes(ctx, req, res, u, parts) {
5
+ const send = (status, data) => {
6
+ json(res, status, data);
7
+ return true;
8
+ };
9
+ if (req.method === 'GET' && u.pathname === '/api/v1/agents/models') {
10
+ const workspaceId = u.searchParams.get('workspaceId') ?? '', relativeCwd = u.searchParams.get('relativeCwd') ?? '.';
11
+ return send(200, { data: await ctx.agents.capabilityPreview(workspaceId, relativeCwd) });
12
+ }
13
+ // The list carries which template an Agent uses but not its text; GET /agents/:id/system-prompt has the full prompt.
14
+ if (req.method === 'GET' && u.pathname === '/api/v1/agents')
15
+ return send(200, {
16
+ data: ctx.agents
17
+ .list()
18
+ .map(({ template, ...record }) => template
19
+ ? { ...record, template: { id: template.id, name: template.name, mode: template.mode } }
20
+ : record)
21
+ });
22
+ if (req.method === 'POST' && u.pathname === '/api/v1/agents') {
23
+ const b = await ctx.body(), profile = b.profile === undefined ? 'default' : b.profile;
24
+ if (profile !== 'default' && profile !== 'collab')
25
+ throw Object.assign(new Error('profile must be default or collab'), { code: 'INVALID_REQUEST' });
26
+ let sessionFile;
27
+ if (b.sessionFile) {
28
+ const workspace = ctx.workspaces.get(b.workspaceId);
29
+ if (!workspace)
30
+ throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
31
+ const cwd = await ctx.workspaces.resolve(workspace, b.relativeCwd ?? '.');
32
+ await ctx.ensureSessionIndex(workspace, cwd);
33
+ if (!ctx.metadata.hasSession(cwd, b.sessionFile))
34
+ throw Object.assign(new Error('Session is not valid for cwd'), { code: 'SESSION_NOT_FOUND' });
35
+ sessionFile = b.sessionFile;
36
+ }
37
+ if (b.templateId !== undefined && b.templateId !== null && typeof b.templateId !== 'string')
38
+ throw Object.assign(new Error('templateId must be a string'), { code: 'INVALID_REQUEST' });
39
+ // The template is copied now; later edits to the template file do not affect this Agent.
40
+ const template = b.templateId ? ctx.templates.snapshot(b.templateId) : undefined;
41
+ const agent = await ctx.agents.create(b.workspaceId, b.relativeCwd ?? '.', sessionFile, profile, template);
42
+ try {
43
+ if (b.model !== undefined) {
44
+ if (!b.model || typeof b.model.provider !== 'string' || typeof b.model.modelId !== 'string')
45
+ throw Object.assign(new Error('model.provider and model.modelId are required'), {
46
+ code: 'INVALID_REQUEST'
47
+ });
48
+ await ctx.agents.setModel(agent.agentId, b.model.provider, b.model.modelId);
49
+ }
50
+ if (b.sessionName !== undefined) {
51
+ if (typeof b.sessionName !== 'string')
52
+ throw Object.assign(new Error('sessionName must be a string'), { code: 'INVALID_REQUEST' });
53
+ await ctx.agents.setSessionName(agent.agentId, b.sessionName);
54
+ }
55
+ const configured = ctx.agents.get(agent.agentId).record;
56
+ ctx.metadata.saveAgent(configured);
57
+ return send(201, { data: { ...configured } });
58
+ }
59
+ catch (error) {
60
+ await ctx.agents.remove(agent.agentId).catch(() => { });
61
+ throw error;
62
+ }
63
+ }
64
+ if (parts[2] === 'agents') {
65
+ const id = parts[3];
66
+ if (req.method === 'GET' && parts.length === 4)
67
+ return send(200, { data: ctx.agents.get(id).record });
68
+ if (req.method === 'DELETE' && parts.length === 4) {
69
+ await ctx.agents.dispose(id);
70
+ ctx.metadata.saveAgent(ctx.agents.get(id).record);
71
+ return send(200, { data: { stopped: true } });
72
+ }
73
+ if (req.method === 'POST' && parts[4] === 'archive') {
74
+ const record = await ctx.agents.archive(id);
75
+ ctx.metadata.archiveAgent(record);
76
+ return send(200, { data: { archived: true } });
77
+ }
78
+ if (req.method === 'GET' && parts[4] === 'state')
79
+ return send(200, { data: await ctx.agents.state(id) });
80
+ if (req.method === 'GET' && parts[4] === 'timeline' && parts.length === 5)
81
+ return send(200, {
82
+ data: await ctx.agents.timeline(id, Number(u.searchParams.get('limit') ?? 50), u.searchParams.get('before') ?? undefined)
83
+ });
84
+ // /agents/:id/sessions/:sessionId/items/:itemId — items never change, and the session is part of the
85
+ // URL so a cached body is never served for another one: let the browser keep it for good.
86
+ if (req.method === 'GET' && parts[4] === 'sessions' && parts[6] === 'items' && parts.length === 8) {
87
+ const detail = await ctx.agents.item(id, decodeURIComponent(parts[5]), decodeURIComponent(parts[7]));
88
+ res.setHeader('cache-control', 'private, max-age=31536000, immutable');
89
+ return send(200, { data: detail });
90
+ }
91
+ if (req.method === 'GET' && parts[4] === 'system-prompt')
92
+ return send(200, { data: await ctx.agents.systemPrompt(id) });
93
+ if (req.method === 'GET' && parts[4] === 'capabilities')
94
+ return send(200, { data: await ctx.agents.capabilities(id) });
95
+ if (req.method === 'GET' && parts[4] === 'session')
96
+ return send(200, {
97
+ data: u.searchParams.get('summary') === 'true'
98
+ ? await ctx.agents.sessionInfo(id)
99
+ : await ctx.agents.session(id)
100
+ });
101
+ if (req.method === 'POST' && parts[4] === 'compact') {
102
+ const b = await ctx.body();
103
+ return send(200, { data: await ctx.agents.compact(id, b.instructions) });
104
+ }
105
+ if (req.method === 'POST' && parts[4] === 'model') {
106
+ const b = await ctx.body();
107
+ await ctx.agents.setModel(id, b.provider, b.modelId);
108
+ return send(200, { data: await ctx.agents.capabilities(id) });
109
+ }
110
+ if (req.method === 'POST' && parts[4] === 'thinking') {
111
+ const b = await ctx.body();
112
+ await ctx.agents.setThinkingLevel(id, b.level);
113
+ return send(200, { data: await ctx.agents.capabilities(id) });
114
+ }
115
+ if (req.method === 'POST' && parts[4] === 'session-name') {
116
+ const b = await ctx.body();
117
+ await ctx.agents.setSessionName(id, b.name);
118
+ const summary = await ctx.agents.sessionInfo(id), record = ctx.agents.get(id).record;
119
+ ctx.metadata.saveAgent(record);
120
+ ctx.metadata.upsertSessionFromAgent(record, summary);
121
+ return send(200, { data: summary });
122
+ }
123
+ if (req.method === 'POST' && parts[4] === 'navigate') {
124
+ const b = await ctx.body();
125
+ return send(200, { data: await ctx.agents.navigate(id, b.entryId) });
126
+ }
127
+ if (req.method === 'POST' && parts[4] === 'fork') {
128
+ const b = await ctx.body();
129
+ const agent = await ctx.agents.fork(id, b.entryId);
130
+ ctx.metadata.saveAgent(agent.agent);
131
+ return send(201, { data: agent });
132
+ }
133
+ if (req.method === 'GET' && parts[4] === 'commands')
134
+ return send(200, { data: await ctx.agents.listCommands(id) });
135
+ if (req.method === 'POST' && parts[4] === 'bash') {
136
+ const b = await ctx.body();
137
+ if (typeof b.command !== 'string')
138
+ throw Object.assign(new Error('command must be a string'), { code: 'INVALID_REQUEST' });
139
+ const command = b.command.trim();
140
+ if (!command)
141
+ throw Object.assign(new Error('command is required'), { code: 'INVALID_REQUEST' });
142
+ if (command.length > 8000)
143
+ throw Object.assign(new Error('command is too long'), { code: 'INVALID_REQUEST' });
144
+ const result = await ctx.agents.runBash(id, command, b.excludeFromContext === true);
145
+ ctx.metadata.scheduleAgent(ctx.agents.get(id).record);
146
+ return send(200, { data: result });
147
+ }
148
+ if (req.method === 'POST' && parts[4] === 'bash-abort') {
149
+ await ctx.agents.abortBash(id);
150
+ return send(200, { data: { aborted: true } });
151
+ }
152
+ if (parts[4] === 'queue') {
153
+ if (req.method === 'GET')
154
+ return send(200, { data: await ctx.agents.queue(id) });
155
+ if (req.method === 'DELETE')
156
+ return send(200, { data: await ctx.agents.clearQueue(id) });
157
+ }
158
+ if (parts[4] === 'mcp') {
159
+ const cwd = ctx.agents.get(id).record.cwd;
160
+ if (req.method === 'GET')
161
+ return send(200, { data: listMcpServers(cwd) });
162
+ if (req.method === 'POST') {
163
+ const b = await ctx.body();
164
+ if (typeof b.name !== 'string' || typeof b.enabled !== 'boolean')
165
+ throw Object.assign(new Error('name and enabled are required'), { code: 'INVALID_MCP_REQUEST' });
166
+ return send(200, { data: setMcpServerEnabled(cwd, b.name, b.enabled) });
167
+ }
168
+ }
169
+ if (req.method === 'POST' && parts[4] === 'extension-response') {
170
+ const b = await ctx.body();
171
+ await ctx.agents.extensionResponse(id, b.requestId, b.value);
172
+ return send(200, { data: { success: true } });
173
+ }
174
+ if (req.method === 'POST' && parts[4] === 'transcribe') {
175
+ if (!ctx.voice)
176
+ throw Object.assign(new Error('Voice service is disabled'), { code: 'VOICE_DISABLED' });
177
+ ctx.agents.get(id);
178
+ const audio = await ctx.binaryBody(), mime = String(req.headers['content-type'] ?? 'audio/webm').split(';')[0], filename = String(req.headers['x-audio-filename'] ?? 'recording.webm');
179
+ return send(200, { data: await ctx.voice.transcribe(audio, mime, filename) });
180
+ }
181
+ if (req.method === 'POST' && ['prompt', 'steer', 'follow-up', 'abort'].includes(parts[4])) {
182
+ const b = await ctx.body();
183
+ if (parts[4] !== 'abort') {
184
+ ctx.voice?.recordUserPrompt(id, String(b.message ?? ''));
185
+ ctx.sessionNamer?.recordUserPrompt(id, String(b.message ?? ''));
186
+ }
187
+ const disposition = await ctx.agents.command(id, parts[4], b.message ?? '');
188
+ const record = ctx.agents.get(id).record;
189
+ ctx.metadata.scheduleAgent(record);
190
+ if (parts[4] === 'prompt')
191
+ void ctx.indexAgentSession(id);
192
+ return send(200, { data: { success: true, disposition } });
193
+ }
194
+ }
195
+ return false;
196
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Rate limits pairing attempts per source address, with an escalating lock after repeated failures.
3
+ * Reverse proxies and tunnels share one bucket by design.
4
+ */
5
+ export class LoginLimiter {
6
+ failures = new Map();
7
+ windowMs = 60_000;
8
+ maxFailures = 10;
9
+ lockMs = 60_000;
10
+ maxLockMs = 15 * 60_000;
11
+ /** Milliseconds until `key` may try again; 0 when it is not locked. */
12
+ retryAfterMs(key) {
13
+ const entry = this.failures.get(key);
14
+ if (!entry)
15
+ return 0;
16
+ const now = Date.now();
17
+ if (entry.lockedUntil > now)
18
+ return entry.lockedUntil - now;
19
+ if (entry.expiresAt <= now)
20
+ this.failures.delete(key);
21
+ return 0;
22
+ }
23
+ /** Records a failed attempt and returns the lock duration it triggered, or 0. */
24
+ recordFailure(key) {
25
+ const now = Date.now();
26
+ if (this.failures.size > 1000)
27
+ for (const [id, value] of this.failures)
28
+ if (value.expiresAt <= now && value.lockedUntil <= now)
29
+ this.failures.delete(id);
30
+ const entry = this.failures.get(key);
31
+ if (!entry || (entry.expiresAt <= now && entry.lockedUntil <= now)) {
32
+ this.failures.set(key, { count: 1, expiresAt: now + this.windowMs, lockedUntil: 0, lockLevel: 0 });
33
+ return 0;
34
+ }
35
+ entry.count += 1;
36
+ entry.expiresAt = now + this.windowMs;
37
+ if (entry.count < this.maxFailures)
38
+ return 0;
39
+ entry.count = 0;
40
+ entry.lockLevel = Math.min(entry.lockLevel + 1, 5);
41
+ entry.lockedUntil = now + Math.min(this.lockMs * 2 ** (entry.lockLevel - 1), this.maxLockMs);
42
+ entry.expiresAt = entry.lockedUntil + this.windowMs;
43
+ return entry.lockedUntil - now;
44
+ }
45
+ /** A successful login clears the address's failure history. */
46
+ clear(key) {
47
+ this.failures.delete(key);
48
+ }
49
+ /** Forgets every address. */
50
+ reset() {
51
+ this.failures.clear();
52
+ }
53
+ }
@@ -0,0 +1,53 @@
1
+ import { json, readJsonBody } from './utils.js';
2
+ /** Handles `/api/v1/mail-notifications`. Returns false when the request is not a mail settings route. */
3
+ export async function handleMailSettingsRoutes(ctx, req, res, u) {
4
+ const send = (status, data) => {
5
+ json(res, status, data);
6
+ return true;
7
+ };
8
+ if (req.method === 'GET' && u.pathname === '/api/v1/mail-notifications')
9
+ return send(200, {
10
+ data: { available: !!ctx.mailNotifier, settings: ctx.mailNotifier?.getSettings() ?? null }
11
+ });
12
+ if (req.method === 'POST' && u.pathname === '/api/v1/mail-notifications') {
13
+ if (!ctx.mailNotifier)
14
+ throw Object.assign(new Error('MailDispatch is not configured at server startup'), {
15
+ code: 'MAIL_NOTIFICATIONS_DISABLED'
16
+ });
17
+ const b = await readJsonBody(req), patch = {};
18
+ if ('enabled' in b) {
19
+ if (typeof b.enabled !== 'boolean')
20
+ throw Object.assign(new Error('enabled must be a boolean'), {
21
+ code: 'INVALID_MAIL_NOTIFICATION_SETTINGS'
22
+ });
23
+ patch.enabled = b.enabled;
24
+ }
25
+ if ('aggregationDelaySeconds' in b)
26
+ patch.aggregationDelaySeconds = Number(b.aggregationDelaySeconds);
27
+ if ('includeResponse' in b) {
28
+ if (typeof b.includeResponse !== 'boolean')
29
+ throw Object.assign(new Error('includeResponse must be a boolean'), {
30
+ code: 'INVALID_MAIL_NOTIFICATION_SETTINGS'
31
+ });
32
+ patch.includeResponse = b.includeResponse;
33
+ }
34
+ if ('includeSessionDetails' in b) {
35
+ if (typeof b.includeSessionDetails !== 'boolean')
36
+ throw Object.assign(new Error('includeSessionDetails must be a boolean'), {
37
+ code: 'INVALID_MAIL_NOTIFICATION_SETTINGS'
38
+ });
39
+ patch.includeSessionDetails = b.includeSessionDetails;
40
+ }
41
+ if ('collabEscalations' in b) {
42
+ if (typeof b.collabEscalations !== 'boolean')
43
+ throw Object.assign(new Error('collabEscalations must be a boolean'), {
44
+ code: 'INVALID_MAIL_NOTIFICATION_SETTINGS'
45
+ });
46
+ patch.collabEscalations = b.collabEscalations;
47
+ }
48
+ const settings = ctx.mailNotifier.updateSettings(patch);
49
+ ctx.metadata.setSetting('mailNotifications', settings);
50
+ return send(200, { data: { available: true, settings } });
51
+ }
52
+ return false;
53
+ }