pi2web 0.2.1 → 0.2.8

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 (54) hide show
  1. package/README.md +53 -6
  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 +671 -152
  6. package/dist/auth.js +3 -1
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +45 -10
  9. package/dist/collab/hub.js +1392 -384
  10. package/dist/collab/pi-extension.js +322 -65
  11. package/dist/collab/review-flow.js +197 -48
  12. package/dist/collab/routes.js +94 -23
  13. package/dist/collab/schemas.js +69 -13
  14. package/dist/collab/scoring-flow.js +145 -39
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +467 -112
  17. package/dist/collab/types.js +94 -10
  18. package/dist/collab/validate.js +62 -25
  19. package/dist/drafts.js +26 -6
  20. package/dist/http/agent-routes.js +222 -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 +47 -0
  25. package/dist/http/web-assets.js +64 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +63 -17
  28. package/dist/main.js +61 -16
  29. package/dist/mcp.js +24 -5
  30. package/dist/metadata-store.js +203 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +249 -45
  33. package/dist/server.js +488 -614
  34. package/dist/session-namer.js +66 -17
  35. package/dist/session-performance.js +137 -0
  36. package/dist/sotto-stt.js +217 -0
  37. package/dist/terminals.js +94 -27
  38. package/dist/timeline.js +185 -0
  39. package/dist/voice.js +153 -35
  40. package/dist/workspaces.js +91 -26
  41. package/dist/ws/agent-socket.js +256 -0
  42. package/dist/ws/terminal-socket.js +46 -0
  43. package/npm-shrinkwrap.json +3685 -0
  44. package/package.json +11 -1
  45. package/packages/protocol/openapi.yaml +24 -2
  46. package/packages/protocol/websocket.schema.json +58 -15
  47. package/web/activity-view.js +121 -0
  48. package/web/app.js +3647 -756
  49. package/web/backend-state.js +59 -27
  50. package/web/collab.js +684 -257
  51. package/web/hid-keyboard.js +173 -140
  52. package/web/index.html +7 -2
  53. package/web/styles.css +21 -2
  54. package/web/voice-pcm.js +36 -0
@@ -1,6 +1,28 @@
1
1
  /** Shared contracts for the multi-agent collaboration hub. Everything crossing the HTTP boundary is defined here. */
2
- export const REVIEW_PHASES = ['draft', 'implementing', 'collecting', 'consolidating', 'validating', 'merge_voting', 'issue_discussing', 'issue_reconsidering', 'awaiting_human', 'finished'];
3
- export const SCORING_PHASES = ['nominating', 'consolidating', 'voting', 'rubric_locked', 'scoring', 'analysis', 'debating', 'rescoring', 'awaiting_human', 'finalized'];
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
+ ];
4
26
  export const ROLES = ['implementer', 'reviewer', 'moderator', 'human'];
5
27
  /**
6
28
  * Roles a *seat* may be registered with. `human` is deliberately excluded: a human acts with the pairing
@@ -10,26 +32,88 @@ export const ROLES = ['implementer', 'reviewer', 'moderator', 'human'];
10
32
  export const REGISTRABLE_ROLES = ['implementer', 'reviewer'];
11
33
  export const CAPABILITIES = {
12
34
  implementer: ['file_finding', 'ready', 'withdraw', 'clarify', 'escalate'],
13
- reviewer: ['file_finding', 'withdraw', 'nominate', 'vote', 'score', 'debate', 'clarify', 'merge', 'escalate'],
35
+ reviewer: [
36
+ 'file_finding',
37
+ 'withdraw',
38
+ 'nominate',
39
+ 'vote',
40
+ 'score',
41
+ 'debate',
42
+ 'clarify',
43
+ 'merge',
44
+ 'escalate'
45
+ ],
14
46
  moderator: ['file_finding', 'withdraw', 'merge', 'clarify', 'escalate'],
15
- human: ['file_finding', 'ready', 'withdraw', 'nominate', 'vote', 'score', 'debate', 'clarify', 'merge', '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
+ ]
16
59
  };
17
60
  export const can = (role, capability) => CAPABILITIES[role]?.includes(capability) ?? false;
18
61
  export const SEVERITIES = ['blocker', 'critical', 'major', 'minor', 'nit'];
19
- export const CATEGORIES = ['security', 'correctness', 'performance', 'maintainability', 'style', 'test', 'docs', 'process'];
62
+ export const CATEGORIES = [
63
+ 'security',
64
+ 'correctness',
65
+ 'performance',
66
+ 'maintainability',
67
+ 'style',
68
+ 'test',
69
+ 'docs',
70
+ 'process'
71
+ ];
20
72
  export const REQUIRED_ACTIONS = ['must_fix', 'should_fix', 'discuss', 'fyi'];
21
- export const ISSUE_STATUSES = ['open', 'confirmed', 'answered', 'resolved', 'escalated', 'human_ruled', 'wontfix', 'closed', 'duplicate', 'withdrawn'];
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
+ ];
22
85
  /** Statuses that still block the session from finishing. */
23
86
  export const OPEN_ISSUE_STATUSES = ['open', 'answered', 'escalated'];
24
- export const ESCALATION_KINDS = ['issue_dispute', 'rubric_dispute', 'score_dispute', 'budget_exhausted', 'other'];
87
+ export const ESCALATION_KINDS = [
88
+ 'issue_dispute',
89
+ 'rubric_dispute',
90
+ 'score_dispute',
91
+ 'budget_exhausted',
92
+ 'other'
93
+ ];
25
94
  export const VOTE_STANCES = ['approve', 'reject', 'abstain'];
26
95
  export const DEBATE_STANCES = ['raise', 'lower', 'hold', 'clarify'];
27
96
  /** approvalThreshold is "two thirds" with a little headroom, so an exact 2-of-3 vote passes. */
28
97
  export const DEFAULT_POLICY = {
29
- maxIssueRounds: 3, maxTotalRounds: 6, overdueWarningSec: 1800, autoEscalateOnDeadlock: true, blindFindings: true,
30
- consensusReview: false, maxConsensusRounds: 3, implementationFirst: false,
98
+ maxIssueRounds: 3,
99
+ maxTotalRounds: 6,
100
+ overdueWarningSec: 1800,
101
+ autoEscalateOnDeadlock: true,
102
+ blindFindings: true,
103
+ consensusReview: false,
104
+ maxConsensusRounds: 3,
105
+ implementationFirst: false,
31
106
  tokenBudgetPerParticipant: 600_000,
32
- scoring: { minCriteria: 4, maxCriteria: 8, approvalThreshold: 0.66, maxVotingRounds: 3, scale: { min: 0, max: 10, step: 0.5 }, convergenceRange: 2, maxDebateRounds: 2, blindScoring: true }
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
+ }
33
117
  };
34
118
  /** Error codes returned to agents. Keep them stable: agents branch on these strings. */
35
119
  export const COLLAB_ERRORS = {
@@ -6,9 +6,15 @@ export class ValidationError extends Error {
6
6
  this.fieldErrors = fieldErrors;
7
7
  }
8
8
  }
9
- const fail = (ctx, code, message, expected) => { ctx.errors.push({ path: ctx.path || '(root)', code, message, ...(expected ? { expected } : {}) }); };
10
- const child = (ctx, key) => ({ path: typeof key === 'number' ? `${ctx.path}[${key}]` : ctx.path ? `${ctx.path}.${key}` : key, errors: ctx.errors });
11
- export const str = (options = {}) => ({ parse(value, ctx) {
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) {
12
18
  if (typeof value !== 'string') {
13
19
  fail(ctx, 'NOT_A_STRING', 'Expected a string', 'string');
14
20
  return '';
@@ -22,9 +28,15 @@ export const str = (options = {}) => ({ parse(value, ctx) {
22
28
  if (options.pattern && !options.pattern.test(text))
23
29
  fail(ctx, 'PATTERN_MISMATCH', `Expected a value matching ${options.pattern}`, String(options.pattern));
24
30
  return text;
25
- } });
26
- export const num = (options = {}) => ({ parse(value, ctx) {
27
- const parsed = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN;
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;
28
40
  if (!Number.isFinite(parsed)) {
29
41
  fail(ctx, 'NOT_A_NUMBER', 'Expected a finite number', 'number');
30
42
  return 0;
@@ -42,19 +54,28 @@ export const num = (options = {}) => ({ parse(value, ctx) {
42
54
  fail(ctx, 'NOT_A_MULTIPLE', `Expected a multiple of ${options.step}`, `multiple of ${options.step}`);
43
55
  }
44
56
  return parsed;
45
- } });
46
- export const bool = () => ({ parse(value, ctx) { if (typeof value !== 'boolean') {
47
- fail(ctx, 'NOT_A_BOOLEAN', 'Expected true or false', 'boolean');
48
- return false;
49
- } return value; } });
50
- export const oneOf = (values) => ({ parse(value, ctx) {
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) {
51
70
  if (typeof value !== 'string' || !values.includes(value)) {
52
71
  fail(ctx, 'NOT_ALLOWED', `Expected one of: ${values.join(', ')}`, values.join('|'));
53
72
  return values[0];
54
73
  }
55
74
  return value;
56
- } });
57
- export const arr = (item, options = {}) => ({ parse(value, ctx) {
75
+ }
76
+ });
77
+ export const arr = (item, options = {}) => ({
78
+ parse(value, ctx) {
58
79
  if (!Array.isArray(value)) {
59
80
  fail(ctx, 'NOT_AN_ARRAY', 'Expected an array', 'array');
60
81
  return [];
@@ -67,12 +88,14 @@ export const arr = (item, options = {}) => ({ parse(value, ctx) {
67
88
  return [];
68
89
  }
69
90
  return value.map((entry, index) => item.parse(entry, child(ctx, index)));
70
- } });
91
+ }
92
+ });
71
93
  /**
72
94
  * A map whose keys are data, not schema - rubric anchors are `{"0":"...","10":"..."}`. `obj()` cannot express
73
95
  * this: it only ever copies the fields its shape declares, so an empty shape silently parses to `{}`.
74
96
  */
75
- export const record = (value, limits = {}) => ({ parse(input, ctx) {
97
+ export const record = (value, limits = {}) => ({
98
+ parse(input, ctx) {
76
99
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
77
100
  fail(ctx, 'NOT_AN_OBJECT', 'Expected an object', 'object');
78
101
  return {};
@@ -86,8 +109,10 @@ export const record = (value, limits = {}) => ({ parse(input, ctx) {
86
109
  result[key] = value.parse(source[key], child(ctx, key));
87
110
  }
88
111
  return result;
89
- } });
90
- export const obj = (shape) => ({ parse(value, ctx) {
112
+ }
113
+ });
114
+ export const obj = (shape) => ({
115
+ parse(value, ctx) {
91
116
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
92
117
  fail(ctx, 'NOT_AN_OBJECT', 'Expected an object', 'object');
93
118
  return {};
@@ -110,24 +135,35 @@ export const obj = (shape) => ({ parse(value, ctx) {
110
135
  if (!(key in shape))
111
136
  fail(child(ctx, key), 'UNKNOWN_FIELD', `Unknown field. Allowed fields: ${Object.keys(shape).join(', ')}`);
112
137
  return result;
113
- } });
138
+ }
139
+ });
114
140
  /**
115
141
  * Cross-field rule on top of a structurally valid value. Field-by-field checks cannot catch
116
142
  * `{min:10,max:1}`: every field is in range, yet the combination is unusable.
117
143
  * The check only runs when the inner validator produced no errors, so it never reports on garbage.
118
144
  */
119
- export const refine = (inner, check) => ({ parse(value, ctx) {
145
+ export const refine = (inner, check) => ({
146
+ parse(value, ctx) {
120
147
  const before = ctx.errors.length, result = inner.parse(value, ctx);
121
148
  if (ctx.errors.length > before)
122
149
  return result;
123
150
  for (const error of check(result) ?? [])
124
151
  ctx.errors.push({ ...error, path: ctx.path ? `${ctx.path}.${error.path}` : error.path });
125
152
  return result;
126
- } });
127
- export const optional = (inner) => ({ parse: (value, ctx) => inner.parse(value, ctx), optional: true });
128
- export const withDefault = (inner, fallback) => ({ parse: (value, ctx) => inner.parse(value, ctx), optional: true, fallback });
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
+ });
129
164
  /** Accepts any JSON value. Used for opaque payloads the hub stores but never interprets. */
130
- export const anyJson = (maxBytes = 64 * 1024) => ({ parse(value, ctx) {
165
+ export const anyJson = (maxBytes = 64 * 1024) => ({
166
+ parse(value, ctx) {
131
167
  let size = 0;
132
168
  try {
133
169
  size = JSON.stringify(value)?.length ?? 0;
@@ -139,7 +175,8 @@ export const anyJson = (maxBytes = 64 * 1024) => ({ parse(value, ctx) {
139
175
  if (size > maxBytes)
140
176
  fail(ctx, 'TOO_LARGE', `Expected at most ${maxBytes} serialized bytes, received ${size}`);
141
177
  return value;
142
- } });
178
+ }
179
+ });
143
180
  export function parse(validator, value) {
144
181
  const errors = [], result = validator.parse(value, { path: '', errors });
145
182
  if (errors.length)
package/dist/drafts.js CHANGED
@@ -7,7 +7,13 @@ export class DraftHub {
7
7
  /** Returns the stored draft and how many Web UIs were reached right now. */
8
8
  add(input) {
9
9
  this.expire();
10
- const draft = { id: `draft-${randomUUID()}`, text: input.text, mode: input.mode ?? 'append', source: input.source, createdAt: new Date().toISOString() };
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
+ };
11
17
  this.pending.push(draft);
12
18
  while (this.pending.length > this.max)
13
19
  this.pending.shift();
@@ -17,14 +23,28 @@ export class DraftHub {
17
23
  return { draft, delivered };
18
24
  }
19
25
  /** First acknowledgement wins: other tabs that already received it simply keep their copy. */
20
- ack(id) { const before = this.pending.length; this.pending = this.pending.filter(d => d.id !== id); return this.pending.length < before; }
21
- list() { this.expire(); return [...this.pending]; }
22
- subscriberCount() { return this.listeners.size; }
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
+ }
23
38
  subscribe(listener) {
24
39
  this.listeners.add(listener);
25
40
  for (const draft of this.list())
26
41
  listener(draft, true);
27
- return () => { this.listeners.delete(listener); };
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);
28
49
  }
29
- expire() { const cutoff = Date.now() - this.ttlMs; this.pending = this.pending.filter(d => Date.parse(d.createdAt) >= cutoff); }
30
50
  }
@@ -0,0 +1,222 @@
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
+ if (req.method === 'GET' && parts[4] === 'performance' && parts.length === 5)
85
+ return send(200, { data: await ctx.agents.performance(id) });
86
+ // /agents/:id/sessions/:sessionId/items/:itemId — items never change, and the session is part of the
87
+ // URL so a cached body is never served for another one: let the browser keep it for good.
88
+ if (req.method === 'GET' && parts[4] === 'sessions' && parts[6] === 'items' && parts.length === 8) {
89
+ const detail = await ctx.agents.item(id, decodeURIComponent(parts[5]), decodeURIComponent(parts[7]));
90
+ res.setHeader('cache-control', 'private, max-age=31536000, immutable');
91
+ return send(200, { data: detail });
92
+ }
93
+ if (req.method === 'GET' && parts[4] === 'system-prompt')
94
+ return send(200, { data: await ctx.agents.systemPrompt(id) });
95
+ if (req.method === 'POST' && parts[4] === 'system-prompt') {
96
+ const b = await ctx.body();
97
+ if (b.templateId !== undefined && typeof b.templateId !== 'string')
98
+ throw Object.assign(new Error('templateId must be a string'), { code: 'INVALID_REQUEST' });
99
+ const template = b.templateId ? ctx.templates.snapshot(b.templateId) : undefined;
100
+ const record = await ctx.agents.setTemplate(id, template, candidate => ctx.metadata.commitAgentTemplate(candidate));
101
+ return send(200, { data: { template: record.template ?? null } });
102
+ }
103
+ if (req.method === 'GET' && parts[4] === 'capabilities')
104
+ return send(200, { data: await ctx.agents.capabilities(id) });
105
+ if (req.method === 'GET' && parts[4] === 'session')
106
+ return send(200, {
107
+ data: u.searchParams.get('summary') === 'true'
108
+ ? await ctx.agents.sessionInfo(id)
109
+ : await ctx.agents.session(id)
110
+ });
111
+ if (req.method === 'POST' && parts[4] === 'compact') {
112
+ const b = await ctx.body();
113
+ return send(200, { data: await ctx.agents.compact(id, b.instructions) });
114
+ }
115
+ if (req.method === 'POST' && parts[4] === 'model') {
116
+ const b = await ctx.body();
117
+ await ctx.agents.setModel(id, b.provider, b.modelId);
118
+ return send(200, { data: await ctx.agents.capabilities(id) });
119
+ }
120
+ if (req.method === 'POST' && parts[4] === 'thinking') {
121
+ const b = await ctx.body();
122
+ await ctx.agents.setThinkingLevel(id, b.level);
123
+ return send(200, { data: await ctx.agents.capabilities(id) });
124
+ }
125
+ if (req.method === 'POST' && parts[4] === 'session-name') {
126
+ const b = await ctx.body();
127
+ await ctx.agents.setSessionName(id, b.name);
128
+ const summary = await ctx.agents.sessionInfo(id), record = ctx.agents.get(id).record;
129
+ ctx.metadata.saveAgent(record);
130
+ ctx.metadata.upsertSessionFromAgent(record, summary);
131
+ return send(200, { data: summary });
132
+ }
133
+ if (req.method === 'POST' && parts[4] === 'navigate') {
134
+ const b = await ctx.body();
135
+ return send(200, { data: await ctx.agents.navigate(id, b.entryId) });
136
+ }
137
+ if (req.method === 'POST' && parts[4] === 'fork') {
138
+ const b = await ctx.body();
139
+ const agent = await ctx.agents.fork(id, b.entryId);
140
+ ctx.metadata.saveAgent(agent.agent);
141
+ return send(201, { data: agent });
142
+ }
143
+ if (req.method === 'GET' && parts[4] === 'commands')
144
+ return send(200, { data: await ctx.agents.listCommands(id) });
145
+ if (req.method === 'POST' && parts[4] === 'bash') {
146
+ const b = await ctx.body();
147
+ if (typeof b.command !== 'string')
148
+ throw Object.assign(new Error('command must be a string'), { code: 'INVALID_REQUEST' });
149
+ const command = b.command.trim();
150
+ if (!command)
151
+ throw Object.assign(new Error('command is required'), { code: 'INVALID_REQUEST' });
152
+ if (command.length > 8000)
153
+ throw Object.assign(new Error('command is too long'), { code: 'INVALID_REQUEST' });
154
+ const result = await ctx.agents.runBash(id, command, b.excludeFromContext === true);
155
+ ctx.metadata.scheduleAgent(ctx.agents.get(id).record);
156
+ return send(200, { data: result });
157
+ }
158
+ if (req.method === 'POST' && parts[4] === 'bash-abort') {
159
+ await ctx.agents.abortBash(id);
160
+ return send(200, { data: { aborted: true } });
161
+ }
162
+ if (parts[4] === 'queue') {
163
+ if (req.method === 'GET')
164
+ return send(200, { data: await ctx.agents.queue(id) });
165
+ if (req.method === 'DELETE')
166
+ return send(200, { data: await ctx.agents.clearQueue(id) });
167
+ if (req.method === 'POST' && parts[5] === 'move') {
168
+ const { from, index, expected } = await ctx.body();
169
+ if ((from !== 'steering' && from !== 'followUp') ||
170
+ !Number.isSafeInteger(index) ||
171
+ index < 0 ||
172
+ typeof expected !== 'string')
173
+ throw Object.assign(new Error('from, index and expected are required'), { code: 'INVALID_REQUEST' });
174
+ return send(200, { data: await ctx.agents.moveQueue(id, from, index, expected) });
175
+ }
176
+ }
177
+ if (parts[4] === 'mcp') {
178
+ const cwd = ctx.agents.get(id).record.cwd;
179
+ if (req.method === 'GET')
180
+ return send(200, { data: listMcpServers(cwd) });
181
+ if (req.method === 'POST') {
182
+ const b = await ctx.body();
183
+ if (typeof b.name !== 'string' || typeof b.enabled !== 'boolean')
184
+ throw Object.assign(new Error('name and enabled are required'), { code: 'INVALID_MCP_REQUEST' });
185
+ return send(200, { data: setMcpServerEnabled(cwd, b.name, b.enabled) });
186
+ }
187
+ }
188
+ if (req.method === 'POST' && parts[4] === 'extension-response') {
189
+ const b = await ctx.body();
190
+ await ctx.agents.extensionResponse(id, b.requestId, b.value);
191
+ return send(200, { data: { success: true } });
192
+ }
193
+ if (req.method === 'POST' && parts[4] === 'transcribe') {
194
+ ctx.agents.get(id);
195
+ const mime = String(req.headers['content-type'] ?? 'audio/webm').split(';')[0];
196
+ if (ctx.sottoStt.getSettings().enabled) {
197
+ if (mime !== 'application/octet-stream')
198
+ throw Object.assign(new Error('Sotto requires 16 kHz float32 PCM'), { code: 'INVALID_REQUEST' });
199
+ const audio = await ctx.binaryBody();
200
+ return send(200, { data: await ctx.sottoStt.transcribe(audio) });
201
+ }
202
+ if (!ctx.voice)
203
+ throw Object.assign(new Error('Voice service is disabled'), { code: 'VOICE_DISABLED' });
204
+ const audio = await ctx.binaryBody(), filename = String(req.headers['x-audio-filename'] ?? 'recording.webm');
205
+ return send(200, { data: await ctx.voice.transcribe(audio, mime, filename) });
206
+ }
207
+ if (req.method === 'POST' && ['prompt', 'steer', 'follow-up', 'abort'].includes(parts[4])) {
208
+ const b = await ctx.body();
209
+ if (parts[4] !== 'abort') {
210
+ ctx.voice?.recordUserPrompt(id, String(b.message ?? ''));
211
+ ctx.sessionNamer?.recordUserPrompt(id, String(b.message ?? ''));
212
+ }
213
+ const disposition = await ctx.agents.command(id, parts[4], b.message ?? '');
214
+ const record = ctx.agents.get(id).record;
215
+ ctx.metadata.scheduleAgent(record);
216
+ if (parts[4] === 'prompt')
217
+ void ctx.indexAgentSession(id);
218
+ return send(200, { data: { success: true, disposition } });
219
+ }
220
+ }
221
+ return false;
222
+ }
@@ -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
+ }