@openchamber/sdk 2.5.3 → 2.6.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.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Session events for an extension's service (`contributes.sessionEvents`).
3
+ *
4
+ * OpenChamber posts each event a package subscribed to as one HTTP request to
5
+ * the service at `SESSION_EVENTS_PATH`, from every project, whether or not a
6
+ * window is open. Approving the package is the subscription: the request
7
+ * carries identifiers only (`event`), and the `transcript` capability, asked
8
+ * for with `content: true`, adds what the agent said or asked (`content`).
9
+ * The service answers `{ ok: true }` once it has taken the event; OpenChamber
10
+ * reads nothing else from the answer. Which projects an extension acts on is
11
+ * its own business: filter on `event.project.directory`.
12
+ */
13
+ /**
14
+ * `turn-finished`: the session went idle with nothing waiting (no running
15
+ * subagent, no open permission, question or page, no goal or auto-review
16
+ * step still driving it). `turn-failed`: the turn ended in an error.
17
+ * `permission-asked`: the agent asked for a permission the user has to
18
+ * answer. `question-asked`: the agent asked the user a question through a
19
+ * form or a page. A turn the user stopped emits nothing.
20
+ */
21
+ export const GUEST_SESSION_EVENT_TYPES = ['turn-finished', 'turn-failed', 'permission-asked', 'question-asked'];
22
+ /** Route on the service loopback OpenChamber posts every event to. */
23
+ export const SESSION_EVENTS_PATH = '/session-events';
24
+ /** The `v` of every request; a service answers a version it does not know with HTTP 400. */
25
+ export const SESSION_EVENTS_WIRE_VERSION = 1;
26
+ /** One delivery may take this long; a slower service drops that event. */
27
+ export const SESSION_EVENT_TIMEOUT_MS = 10_000;
28
+ /** No event for this long stops the service; the next event starts it again. */
29
+ export const SESSION_EVENTS_IDLE_MS = 10 * 60_000;
30
+ /** Characters of the agent's reply carried in `content`; a longer reply is cut and flagged. */
31
+ export const SESSION_EVENT_REPLY_MAX = 64_000;
32
+ /** Events kept while the service starts; newer ones past this are dropped. */
33
+ export const SESSION_EVENT_PENDING_MAX = 200;
34
+ /** Characters of `error` OpenChamber reads from a failed answer. */
35
+ export const SESSION_EVENT_ERROR_MAX = 2_000;
36
+ const EVENT_TYPES = new Set(GUEST_SESSION_EVENT_TYPES);
37
+ export const isGuestSessionEventType = (value) => EVENT_TYPES.has(value);
38
+ /**
39
+ * A service's read of the body OpenChamber posted to `SESSION_EVENTS_PATH`.
40
+ * OpenChamber built the event, so this checks the envelope and the fields a
41
+ * service branches on, not every content field, and the service bundle needs
42
+ * no schema library. `null` is a body that is not a session event of this
43
+ * wire version; answer it with HTTP 400.
44
+ */
45
+ export const readSessionEventRequest = (body) => {
46
+ let wire;
47
+ try {
48
+ const parsed = JSON.parse(body);
49
+ if (Object(parsed) !== parsed || parsed === null)
50
+ return null;
51
+ wire = parsed;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ const { v, requestId, event, content } = wire;
57
+ if (v !== SESSION_EVENTS_WIRE_VERSION)
58
+ return null;
59
+ if (String(requestId) !== requestId || requestId.length === 0)
60
+ return null;
61
+ if (Object(event) !== event || event === undefined)
62
+ return null;
63
+ const { id, type, at, session, project, linked, permission } = event;
64
+ if (String(id) !== id || id.length === 0)
65
+ return null;
66
+ if (String(type) !== type || !isGuestSessionEventType(type))
67
+ return null;
68
+ if (!Number.isFinite(at))
69
+ return null;
70
+ if (Object(session) !== session || session === undefined)
71
+ return null;
72
+ const sessionId = session.id;
73
+ if (String(sessionId) !== sessionId || sessionId.length === 0)
74
+ return null;
75
+ const parentId = session.parentId;
76
+ if (parentId !== null && (String(parentId) !== parentId || parentId.length === 0))
77
+ return null;
78
+ if (Object(project) !== project || project === undefined)
79
+ return null;
80
+ const directory = project.directory;
81
+ if (directory !== null && (String(directory) !== directory || directory.length === 0))
82
+ return null;
83
+ if (!Array.isArray(linked))
84
+ return null;
85
+ // SAFETY: OpenChamber built `linked` from the session's stored link entries;
86
+ // a service reads each item under `GuestSessionLinkedItem`.
87
+ const request = {
88
+ v: SESSION_EVENTS_WIRE_VERSION,
89
+ requestId,
90
+ event: { id, type, at: Number(at), session: { id: sessionId, parentId }, project: { directory }, linked: linked },
91
+ };
92
+ if (permission !== undefined) {
93
+ if (Object(permission) !== permission)
94
+ return null;
95
+ const permissionRequestId = permission.requestId;
96
+ if (String(permissionRequestId) !== permissionRequestId || permissionRequestId.length === 0)
97
+ return null;
98
+ request.event.permission = { requestId: permissionRequestId };
99
+ }
100
+ if (content !== undefined) {
101
+ if (Object(content) !== content || content.type !== type)
102
+ return null;
103
+ // SAFETY: the content's `type` equals the event's, and OpenChamber built
104
+ // the rest of it for that type (`GuestSessionEventContent`).
105
+ request.content = content;
106
+ }
107
+ return request;
108
+ };
@@ -7,5 +7,9 @@ export type ProgressProps = {
7
7
  label?: string;
8
8
  };
9
9
  export type ProgressHandle = Handle<ProgressProps>;
10
+ /**
11
+ * The whole percent shown: rounded down, so it never reads 100% before the
12
+ * work is done (99.6 shows 99), and 100 only once the value reaches it.
13
+ */
10
14
  export declare const clampProgress: (value: number) => number;
11
15
  export declare const mountProgress: (root: Element, initial: ProgressProps) => ProgressHandle;
@@ -1,14 +1,22 @@
1
1
  import { applyTone } from './badge.js';
2
- import { el, ensureStyle, setText } from './dom.js';
2
+ import { el, ensureStyle, setAttr, setText } from './dom.js';
3
+ import { icon } from './icons.js';
3
4
  import { UI_CSS } from './style.js';
4
- export const clampProgress = (value) => (Number.isFinite(value) ? Math.min(100, Math.max(0, Math.round(value))) : 0);
5
+ /**
6
+ * The whole percent shown: rounded down, so it never reads 100% before the
7
+ * work is done (99.6 shows 99), and 100 only once the value reaches it.
8
+ */
9
+ export const clampProgress = (value) => (Number.isFinite(value) ? Math.min(100, Math.max(0, Math.floor(value))) : 0);
5
10
  export const mountProgress = (root, initial) => {
6
11
  ensureStyle(UI_CSS);
7
12
  let props = initial;
8
13
  const node = el('div', 'oc-sdk oc-sdk-progress');
9
14
  const caption = el('div', 'oc-sdk-progress-label');
10
15
  const label = el('span');
11
- const percent = el('span');
16
+ const percent = el('span', 'oc-sdk-progress-value');
17
+ const percentText = el('span');
18
+ const doneMark = icon('check', 14);
19
+ percent.append(doneMark, percentText);
12
20
  caption.append(label, percent);
13
21
  const track = el('div', 'oc-sdk-progress-track');
14
22
  track.setAttribute('role', 'progressbar');
@@ -28,7 +36,12 @@ export const mountProgress = (root, initial) => {
28
36
  else
29
37
  track.removeAttribute('aria-label');
30
38
  setText(label, props.label);
31
- setText(percent, `${value}%`);
39
+ setText(percentText, `${value}%`);
40
+ // Done reads as done: the app's progress turns green with a check. A bar
41
+ // the extension tinted keeps its tone, as the served look keeps its fill.
42
+ const done = value === 100 && (!props.tone || props.tone === 'neutral');
43
+ setAttr(node, 'data-done', done ? 'true' : null);
44
+ doneMark.style.display = done ? '' : 'none';
32
45
  caption.hidden = !props.label;
33
46
  };
34
47
  paint();
package/dist/ui/style.js CHANGED
@@ -67,30 +67,31 @@ const focusFill = `background-image: linear-gradient(${hover}, ${hover});`;
67
67
  * is that border: an inset ring would paint inside it, 1px in from the edge,
68
68
  * with a strip of fill around it. Fields have no border and keep the ring.
69
69
  */
70
- const field = (edge) => `box-shadow: inset 0 0 0 1px ${edge}, var(--oc-sdk-field-lift);`;
70
+ // Half a pixel at rest; a focused or invalid field takes a full one.
71
+ const field = (edge, width = '0.5px') => `box-shadow: inset 0 0 0 ${width} ${edge}, var(--oc-sdk-field-lift);`;
71
72
  const raised = 'border-color: var(--oc-sdk-edge); box-shadow: var(--oc-sdk-raised-lift);';
72
73
  const litButton = 'box-shadow: var(--oc-sdk-lit);';
73
- const darkLift = `inset 0 1px 0 0 ${mix(fg, 1.5)}, 0 1px 1px 0 rgb(0 0 0 / 0.28), 0 2px 3px -2px rgb(0 0 0 / 0.2)`;
74
+ const darkLift = `inset 0 0.5px 0 0 ${mix(fg, 8)}, 0 1px 2px 0 rgb(0 0 0 / 0.4)`;
74
75
  const THEME_VARS = `
75
76
  :root {
76
- --oc-sdk-edge: ${mix(fg, 10)};
77
+ --oc-sdk-edge: ${mix(fg, 16)};
77
78
  --oc-sdk-field-bg: ${elevated};
78
79
  --oc-sdk-raised-bg: ${bg};
79
80
  --oc-sdk-field-lift: inset 0 1px 2px 0 rgb(0 0 0 / 0.04);
80
- --oc-sdk-raised-lift: 0 1px 2px 0 rgb(0 0 0 / 0.06);
81
- --oc-sdk-lit: inset 0 1px 0 0 rgb(255 255 255 / 0.45), 0 1px 2px 0 rgb(0 0 0 / 0.08);
82
- --oc-sdk-float-edge: rgb(0 0 0 / 0.1);
83
- --oc-sdk-float: inset 0 1px 0 0 rgb(255 255 255 / 0.8), inset 0 0 0 1px rgb(0 0 0 / 0.04), 0 1px 2px -0.5px rgb(0 0 0 / 0.08), 0 4px 8px -2px rgb(0 0 0 / 0.08), 0 12px 20px -4px rgb(0 0 0 / 0.08);
81
+ --oc-sdk-raised-lift: 0 1px 1.5px 0 rgb(0 0 0 / 0.08);
82
+ --oc-sdk-lit: inset 0 1px 0 0 rgb(255 255 255 / 0.2), 0 1px 1.5px 0 rgb(0 0 0 / 0.1);
83
+ --oc-sdk-float-edge: rgb(0 0 0 / 0.14);
84
+ --oc-sdk-float: inset 0 1px 0 0 rgb(255 255 255 / 0.7), 0 1px 2px -0.5px rgb(0 0 0 / 0.06), 0 4px 8px -2px rgb(0 0 0 / 0.08), 0 12px 20px -4px rgb(0 0 0 / 0.06);
84
85
  }
85
86
  :root[data-oc-theme="dark"] {
86
- --oc-sdk-edge: ${mix(fg, 6)};
87
+ --oc-sdk-edge: ${mix(fg, 14)};
87
88
  --oc-sdk-field-bg: ${mix(bg, 50, elevated)};
88
89
  --oc-sdk-raised-bg: ${elevated};
89
90
  --oc-sdk-field-lift: ${darkLift};
90
91
  --oc-sdk-raised-lift: ${darkLift};
91
- --oc-sdk-lit: inset 0 1px 0 0 ${mix(fg, 1.5)}, 0 1px 1px 0 rgb(0 0 0 / 0.28), 0 2px 3px -2px rgb(0 0 0 / 0.2);
92
- --oc-sdk-float-edge: rgb(0 0 0 / 0.36);
93
- --oc-sdk-float: inset 0 1px 0 0 rgb(255 255 255 / 0.06), inset 0 0 0 1px rgb(255 255 255 / 0.08), 0 1px 1px -0.5px rgb(0 0 0 / 0.22), 0 3px 3px -1.5px rgb(0 0 0 / 0.2), 0 6px 6px -3px rgb(0 0 0 / 0.16);
92
+ --oc-sdk-lit: ${darkLift};
93
+ --oc-sdk-float-edge: rgb(0 0 0 / 0.55);
94
+ --oc-sdk-float: inset 0 1px 0 0 rgb(255 255 255 / 0.04), inset 0 0 0 0.5px rgb(255 255 255 / 0.08), 0 8px 16px 0 rgb(0 0 0 / 0.25), 0 4px 8px 0 rgb(0 0 0 / 0.25);
94
95
  }`;
95
96
  const tone = (name) => {
96
97
  const color = v(`status-${name}`, 'currentColor');
@@ -192,6 +193,8 @@ textarea.oc-sdk-input { height: auto; padding: 8px 10px; resize: vertical; }
192
193
  .oc-sdk-progress-label { display: flex; justify-content: space-between; font-size: 0.75rem; font-variant-numeric: tabular-nums; }
193
194
  .oc-sdk-progress-track { height: 6px; overflow: hidden; }
194
195
  .oc-sdk-progress-fill { height: 100%; transform-origin: left; transition: transform 200ms ease-out; }
196
+ .oc-sdk-progress-value { display: inline-flex; align-items: center; gap: 2px; }
197
+ .oc-sdk-progress[data-done="true"] .oc-sdk-progress-value { font-weight: 600; }
195
198
 
196
199
  .oc-sdk-menu { position: relative; display: inline-flex; }
197
200
 
@@ -238,8 +241,8 @@ ${tone('success')}${tone('warning')}${tone('error')}${tone('info')}
238
241
  .oc-sdk-input { border-radius: ${controlRadius}; background: var(--oc-sdk-field-bg); color: ${elevatedFg}; ${field('var(--oc-sdk-edge)')} transition: background 150ms ease-out; }
239
242
  .oc-sdk-input::placeholder { color: ${muted}; }
240
243
  .oc-sdk-input:hover:not(:focus) { background-image: linear-gradient(${hover}, ${hover}); }
241
- .oc-sdk-input:focus, .oc-sdk-input:focus-visible { ${field(mix(fg, 26))} }
242
- .oc-sdk-field[data-invalid="true"] .oc-sdk-input, .oc-sdk-field[data-invalid="true"] .oc-sdk-input:focus { ${field(v('status-error', 'red'))} }
244
+ .oc-sdk-input:focus, .oc-sdk-input:focus-visible { ${field(mix(fg, 26), '1px')} }
245
+ .oc-sdk-field[data-invalid="true"] .oc-sdk-input, .oc-sdk-field[data-invalid="true"] .oc-sdk-input:focus { ${field(v('status-error', 'red'), '1px')} }
243
246
  .oc-sdk-input[data-mono="true"] { font-family: ${mono}; }
244
247
 
245
248
  .oc-sdk-search-icon { color: ${muted}; }
@@ -252,7 +255,7 @@ ${tone('success')}${tone('warning')}${tone('error')}${tone('info')}
252
255
  .oc-sdk-trigger[aria-expanded="true"] { background-image: linear-gradient(${active}, ${active}); }
253
256
  .oc-sdk-trigger-value[data-empty="true"] { color: ${muted}; }
254
257
  .oc-sdk-trigger-chevron { color: ${muted}; }
255
- /* The app's dropdown light: lit top, hairline rings and a layered shadow. Its outer ring is the border, over the page like the app's. */
258
+ /* The app's floating level: the glass edge (a hairline ring and a lit top line, dark outside and light inside in dark themes) over soft shadows. The outer ring is the border, over the page like the app's. */
256
259
  .oc-sdk-popup { --surface-foreground: ${elevatedFg}; border-color: var(--oc-sdk-float-edge); border-radius: 12px; background: ${elevated}; background-clip: padding-box; color: ${elevatedFg}; box-shadow: var(--oc-sdk-float); }
257
260
  .oc-sdk-option { border-radius: 8px; }
258
261
  .oc-sdk-option[data-active="true"] { background: ${hover}; }
@@ -284,7 +287,8 @@ ${tone('success')}${tone('warning')}${tone('error')}${tone('info')}
284
287
  .oc-sdk-tabs[data-track="true"] .oc-sdk-tab[aria-selected="true"] { color: ${elevatedFg}; background: var(--oc-sdk-raised-bg); ${raised} }
285
288
  .oc-sdk-tab-count { color: ${muted}; }
286
289
 
287
- .oc-sdk-badge { border-radius: 9999px; background: ${hover}; color: ${muted}; }
290
+ /* The app's neutral badge: a step of the text colour, the same in every theme. */
291
+ .oc-sdk-badge { border-radius: 9999px; background: ${mix(fg, 6)}; color: ${muted}; }
288
292
  .oc-sdk-badge[data-tone] { color: var(--oc-sdk-tone-text, var(--oc-sdk-tone)); background: ${mix('var(--oc-sdk-tone)', 15)}; }
289
293
 
290
294
  .oc-sdk-row { border-radius: 6px; transition: background 120ms ease-out; }
@@ -310,8 +314,11 @@ ${tone('success')}${tone('warning')}${tone('error')}${tone('info')}
310
314
  .oc-sdk-separator::before, .oc-sdk-separator::after { background: ${mix(border, 40)}; }
311
315
 
312
316
  .oc-sdk-progress-label { color: ${muted}; }
313
- .oc-sdk-progress-track { border-radius: 9999px; background: ${border}; }
317
+ /* The app's progress: a groove a step of the text colour, green once done. Older bundles mark done only through aria-valuenow. */
318
+ .oc-sdk-progress-track { border-radius: 9999px; background: ${mix(fg, 6)}; }
314
319
  .oc-sdk-progress-fill { border-radius: 9999px; background: var(--oc-sdk-tone, ${primary}); }
320
+ .oc-sdk-progress-track[aria-valuenow="100"] > .oc-sdk-progress-fill:not([data-tone]) { background: ${v('status-success', 'currentColor')}; }
321
+ .oc-sdk-progress[data-done="true"] .oc-sdk-progress-value { color: ${v('success-text', 'inherit')}; }
315
322
 
316
323
  .oc-sdk-text a { color: ${primaryText}; text-decoration: underline; text-underline-offset: 2px; }
317
324
  .oc-sdk-text img { border-radius: 8px; border: 1px solid ${mix(border, 60)}; }
@@ -60,6 +60,7 @@ export declare const guestWorkspaceSnapshotSchema: z.ZodDiscriminatedUnion<[z.Zo
60
60
  title: z.ZodString;
61
61
  projectId: z.ZodString;
62
62
  directory: z.ZodString;
63
+ agent: z.ZodOptional<z.ZodString>;
63
64
  parentId: z.ZodNullable<z.ZodString>;
64
65
  createdAt: z.ZodNumber;
65
66
  updatedAt: z.ZodNumber;
@@ -17,7 +17,9 @@ export const guestWorkspaceSnapshotSchema = z.discriminatedUnion('kind', [
17
17
  z.object({ kind: z.literal('sessions'), projectId: identity, state,
18
18
  coverage: z.array(z.object({ directory: identity, state })),
19
19
  sessions: z.array(z.object({
20
- id: identity, title: z.string(), projectId: identity, directory: identity, parentId: identity.nullable(),
20
+ id: identity, title: z.string(), projectId: identity, directory: identity,
21
+ agent: z.string().optional(),
22
+ parentId: identity.nullable(),
21
23
  createdAt: z.number(), updatedAt: z.number(), archivedAt: z.number().nullable(), worktree: worktree.nullable(),
22
24
  activity: z.enum(['unknown', 'idle', 'running', 'retrying', 'waiting-permission', 'waiting-question']),
23
25
  outcome: z.enum(['completed', 'failed']).nullable(), items: z.array(z.object({ id: identity, data: z.json().optional() })),
@@ -29,6 +29,8 @@ export type GuestSessionRecord = {
29
29
  title: string;
30
30
  projectId: string;
31
31
  directory: string;
32
+ /** The agent the session runs as. Absent from hosts older than this field. */
33
+ agent?: string;
32
34
  parentId: string | null;
33
35
  createdAt: number;
34
36
  updatedAt: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchamber/sdk",
3
- "version": "2.5.3",
3
+ "version": "2.6.0",
4
4
  "description": "Contract for OpenChamber guests: manifest parse and iframe host bridge.",
5
5
  "private": false,
6
6
  "license": "MIT",