guidinghand 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,7 +26,7 @@ await gh.sessions.waitForConnection(session.session_id);
26
26
  const task = await gh.tasks.run(session.session_id, {
27
27
  prompt: 'Turn on Dark Mode',
28
28
  onEvent: (e) => console.log(e.type, e.message),
29
- onQuestion: async (q) => 'Work', // q.question, q.options
29
+ onQuestion: async (q) => 'Work', // q.question, q.options (leave it out to let the customer answer on their screen)
30
30
  onApproval: async (a) => a.risk !== 'high', // true/'approve' or false/'deny'
31
31
  });
32
32
 
@@ -36,11 +36,12 @@ console.log('Replay:', task.replay_url);
36
36
 
37
37
  ## Agents
38
38
 
39
- An agent is your configuration: instructions (added under GuidingHand's own rules, which always win), effort and a greeting for the invite page. Every org has a `default` agent.
39
+ An agent is your configuration: instructions (added under GuidingHand's own rules, which always win), effort, a greeting for the invite page, and what the customer sees and may do on their screen. Every org has a `default` agent.
40
40
 
41
41
  ```ts
42
42
  await gh.agents.create({ agent_id: 'billing', name: 'Billing help', instructions: 'Open the Billing app from the Dock.', effort: 'medium', greeting: 'Hi, this is Acme billing.' });
43
43
  await gh.agents.update('billing', { effort: 'high' });
44
+ await gh.agents.update('billing', { display_name: 'Acme Support', narration: true, customer_answers: false }); // on their screen
44
45
  const { data } = await gh.agents.list();
45
46
  await gh.agents.delete('billing');
46
47
  ```
@@ -72,6 +73,26 @@ const page = await gh.tasks.list({ status: 'completed', limit: 50 }); // { da
72
73
  for await (const t of gh.tasks.listAll({ agent_id: 'billing' })) { /* every page */ }
73
74
  ```
74
75
 
76
+ ## Questions the customer answers
77
+
78
+ Unless the agent turns it off (`customer_answers: false`), the agent's questions also appear on the customer's screen, and they can answer them there. `question.customer_can_answer` says whether they can answer the open one. The first answer is used, from them or from you.
79
+
80
+ - Without `onQuestion`, `run()` leaves those questions to the customer and keeps following the task (pass `timeout` to give up eventually). It throws `NeedsInputError` only for a question they can't answer.
81
+ - With `onQuestion`, return `null` to leave one to them (say, when `q.customer_can_answer` is true). If they answer while your handler works, your answer is refused with a 409 and `run()` carries on.
82
+ - `respond()` after they answered throws `ConflictError` with `extra.code === 'already_answered'` and `extra.answered_by === 'customer'`.
83
+ - The `answer` event has `data.answered_by` (`'customer'` or `'operator'`), and the `task.question_answered` webhook carries the answer and who gave it.
84
+
85
+ ```ts
86
+ const task = await gh.tasks.run(sessionId, {
87
+ prompt: 'Set up the office printer',
88
+ onEvent: (e) => { if (e.type === 'answer') console.log(`${e.data?.answered_by} answered: ${e.message}`); },
89
+ onQuestion: async (q) => (q.customer_can_answer ? null : askMyTeam(q.question, q.options)),
90
+ onApproval: async (a) => askMyTeamToApprove(a.action),
91
+ });
92
+ ```
93
+
94
+ Approvals always come from your team.
95
+
75
96
  ## Recordings
76
97
 
77
98
  ```ts
@@ -84,7 +105,7 @@ The console replays them with the agent's cursor at `task.replay_url`.
84
105
  ## Webhooks
85
106
 
86
107
  ```ts
87
- const { secret } = await gh.webhook.update({ url: 'https://example.com/guidinghand', events: ['task.completed', 'task.waiting_for_user'] });
108
+ const { secret } = await gh.webhook.update({ url: 'https://example.com/guidinghand', events: ['task.completed', 'task.waiting_for_user', 'task.question_answered'] });
88
109
  ```
89
110
 
90
111
  Verify each delivery with the raw request body:
@@ -95,6 +116,7 @@ import { verifyWebhook } from 'guidinghand';
95
116
  app.post('/guidinghand', express.raw({ type: 'application/json' }), async (req, res) => {
96
117
  const event = await verifyWebhook(req.body, req.header('GuidingHand-Signature'), process.env.GUIDINGHAND_WEBHOOK_SECRET);
97
118
  if (event.type === 'task.completed') console.log(event.data.task.result);
119
+ if (event.type === 'task.question_answered') console.log(event.data.answer.answered_by, event.data.answer.answer);
98
120
  res.sendStatus(200);
99
121
  });
100
122
  ```
@@ -110,11 +132,11 @@ Every error is a `GuidingHandError` with `status`, `type`, `message` and `extra`
110
132
  | `PaymentRequiredError` | 402 | the org's plan doesn't allow it (free minutes used up) |
111
133
  | `PermissionDeniedError` | 403 | your role can't do this |
112
134
  | `NotFoundError` | 404 | not in this org |
113
- | `ConflictError` | 409 | no computer connected, a task already running (`extra.active_task_id`), nothing pending |
135
+ | `ConflictError` | 409 | no computer connected, a task already running (`extra.active_task_id`), nothing pending, or already answered (`extra.code: 'already_answered'`, `extra.answered_by`) |
114
136
  | `RateLimitError` | 429 | too many requests, or the plan's tasks at once |
115
137
  | `APIError` | 5xx | our side |
116
138
  | `APIConnectionError`, `TimeoutError` | | network |
117
- | `NeedsInputError` | | `run()` got a question or approval without a handler (`error.task`) |
139
+ | `NeedsInputError` | | `run()` got a question (that the customer can't answer) or an approval without a handler (`error.task`) |
118
140
  | `SessionExpiredError` | | `waitForConnection()` on a code that expired |
119
141
 
120
142
  Reads, deletes and starts with a `request_id` are retried on connection errors, 429 and 5xx (`maxRetries`, default 2).
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.1.0";
1
+ export declare const VERSION = "0.2.0";
2
2
  export type Metadata = Record<string, string>;
3
3
  export type Effort = 'low' | 'medium' | 'high' | null;
4
4
  export type Agent = {
@@ -8,6 +8,12 @@ export type Agent = {
8
8
  instructions: string;
9
9
  effort: Effort;
10
10
  greeting: string;
11
+ /** What the app calls the agent on the person's screen; '' is "GuidingHand". */
12
+ display_name: string;
13
+ /** The app shows the agent's thoughts and steps on the person's screen as it works. */
14
+ narration: boolean;
15
+ /** The person at the computer can answer the agent's questions in the app (the team still can; the first answer is used). Approvals always stay with the team. */
16
+ customer_answers: boolean;
11
17
  is_default: boolean;
12
18
  invite_url_template: string;
13
19
  created_at: string | null;
@@ -38,11 +44,13 @@ export type Session = {
38
44
  latest_task_id?: string | null;
39
45
  };
40
46
  export type TaskStatus = 'queued' | 'running' | 'waiting_for_user' | 'waiting_for_approval' | 'completed' | 'failed' | 'stopped';
47
+ /** customer_can_answer: the person at the computer can answer it on their screen too (the first answer is used). */
41
48
  export type Question = {
42
49
  type: 'question';
43
50
  question_id: string;
44
51
  question: string;
45
52
  options: string[];
53
+ customer_can_answer: boolean;
46
54
  };
47
55
  export type Approval = {
48
56
  type: 'approval';
@@ -51,6 +59,9 @@ export type Approval = {
51
59
  risk: 'low' | 'medium' | 'high';
52
60
  };
53
61
  export type EventType = 'started' | 'progress' | 'thinking' | 'action' | 'message' | 'question' | 'answer' | 'approval_required' | 'approved' | 'denied' | 'completed' | 'error' | 'stopped';
62
+ /** Who answered a question: the person at the computer ('customer', in the app) or your team ('operator': the API or the console). */
63
+ export type AnsweredBy = 'customer' | 'operator';
64
+ /** data: e.g. { action } for 'action', { question_id, question, options } for 'question', { question_id, answered_by } for 'answer' (whose message is the answer). */
54
65
  export type TaskEvent = {
55
66
  cursor: number;
56
67
  type: EventType;
@@ -96,7 +107,7 @@ export type Recording = {
96
107
  task_id: string;
97
108
  frames: Frame[];
98
109
  };
99
- export type WebhookEventType = 'session.connected' | 'session.disconnected' | 'task.started' | 'task.waiting_for_user' | 'task.waiting_for_approval' | 'task.completed' | 'task.failed' | 'task.stopped';
110
+ export type WebhookEventType = 'session.connected' | 'session.disconnected' | 'task.started' | 'task.waiting_for_user' | 'task.question_answered' | 'task.waiting_for_approval' | 'task.completed' | 'task.failed' | 'task.stopped';
100
111
  export type Webhook = {
101
112
  object: 'webhook';
102
113
  url: string | null;
@@ -105,6 +116,12 @@ export type Webhook = {
105
116
  secret?: string;
106
117
  event_types: WebhookEventType[];
107
118
  };
119
+ export type QuestionAnswer = {
120
+ question_id: string;
121
+ answer: string;
122
+ answered_by: AnsweredBy;
123
+ };
124
+ /** data.answer: for task.question_answered. */
108
125
  export type WebhookEvent = {
109
126
  id: string;
110
127
  type: WebhookEventType;
@@ -113,6 +130,7 @@ export type WebhookEvent = {
113
130
  data: {
114
131
  task?: Task;
115
132
  session?: Session;
133
+ answer?: QuestionAnswer;
116
134
  };
117
135
  };
118
136
  export type Page<T> = {
@@ -141,6 +159,7 @@ export declare class PermissionDeniedError extends GuidingHandError {
141
159
  }
142
160
  export declare class NotFoundError extends GuidingHandError {
143
161
  }
162
+ /** 409. From respond(): extra.code is 'already_answered' when someone answered first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
144
163
  export declare class ConflictError extends GuidingHandError {
145
164
  }
146
165
  export declare class RateLimitError extends GuidingHandError {
@@ -153,7 +172,7 @@ export declare class TimeoutError extends GuidingHandError {
153
172
  }
154
173
  export declare class SessionExpiredError extends GuidingHandError {
155
174
  }
156
- /** A task waits on a question or approval and `run()` got no handler for it. */
175
+ /** A task waits on a question or approval and `run()` got no handler for it (a question the person at the computer can answer is left to them instead). */
157
176
  export declare class NeedsInputError extends GuidingHandError {
158
177
  task: Task;
159
178
  constructor(task: Task);
@@ -205,6 +224,9 @@ export type AgentCreate = {
205
224
  instructions?: string;
206
225
  effort?: Effort;
207
226
  greeting?: string;
227
+ display_name?: string;
228
+ narration?: boolean;
229
+ customer_answers?: boolean;
208
230
  };
209
231
  export type AgentUpdate = Partial<Omit<AgentCreate, 'agent_id'>>;
210
232
  declare class Agents extends Resource {
@@ -264,8 +286,12 @@ export type Respond = {
264
286
  export type RunOptions = TaskCreate & {
265
287
  /** Every event, as it happens. */
266
288
  onEvent?: (event: TaskEvent, task: Task) => void | Promise<void>;
267
- /** The agent asks something: return the answer. */
268
- onQuestion?: (question: Question, task: Task) => string | Promise<string>;
289
+ /**
290
+ * The agent asks something: return the answer. When the person at the computer can answer it on their screen
291
+ * (question.customer_can_answer), return null to leave it to them; without onQuestion, run() does that for
292
+ * every such question and throws NeedsInputError only for the others. If they answer first, yours is dropped.
293
+ */
294
+ onQuestion?: (question: Question, task: Task) => string | null | undefined | Promise<string | null | undefined>;
269
295
  /** The agent wants to do something consequential: return true (or 'approve') to let it, false (or 'deny', or { decision, note }) not to. */
270
296
  onApproval?: (approval: Approval, task: Task) => boolean | 'approve' | 'deny' | {
271
297
  decision: 'approve' | 'deny';
@@ -305,14 +331,18 @@ declare class Tasks extends Resource {
305
331
  stream(taskId: string, { after }?: {
306
332
  after?: number;
307
333
  }): AsyncGenerator<TaskEvent, Task>;
308
- /** Starts a task and sees it through: events to onEvent, questions to onQuestion, approvals to onApproval. Resolves with the finished task. */
334
+ /** Starts a task and sees it through: events to onEvent, questions to onQuestion (or the person at the computer), approvals to onApproval. Resolves with the finished task. */
309
335
  run(sessionId: string, { onEvent, onQuestion, onApproval, timeout, ...params }: RunOptions): Promise<Task>;
310
336
  }
311
337
  declare class WebhookEndpoint extends Resource {
312
338
  retrieve(): Promise<Webhook>;
313
- /** Sets the endpoint. The signing secret is in the response when it's new (or with rotate_secret), never again. */
339
+ /**
340
+ * Sets the endpoint. Only the fields you pass change: without url the endpoint stays, without events the
341
+ * filter stays, so { rotate_secret: true } alone makes a new secret. The signing secret is in the response
342
+ * when it's new (or rotated), never again.
343
+ */
314
344
  update(params: {
315
- url: string;
345
+ url?: string;
316
346
  events?: WebhookEventType[];
317
347
  rotate_secret?: boolean;
318
348
  }): Promise<Webhook>;
package/dist/cjs/index.js CHANGED
@@ -8,10 +8,11 @@
8
8
  // // send session.invite_url to the person at the computer, then:
9
9
  // await gh.sessions.waitForConnection(session.session_id);
10
10
  // const task = await gh.tasks.run(session.session_id, { prompt: 'Turn on Dark Mode', onQuestion: async (q) => 'Work', onApproval: async () => true });
11
+ // // (without onQuestion, questions the person at the computer can answer on their screen are left to them)
11
12
  Object.defineProperty(exports, "__esModule", { value: true });
12
13
  exports.GuidingHand = exports.WebhookVerificationError = exports.NeedsInputError = exports.SessionExpiredError = exports.TimeoutError = exports.APIConnectionError = exports.APIError = exports.RateLimitError = exports.ConflictError = exports.NotFoundError = exports.PermissionDeniedError = exports.PaymentRequiredError = exports.AuthenticationError = exports.InvalidRequestError = exports.GuidingHandError = exports.VERSION = void 0;
13
14
  exports.verifyWebhook = verifyWebhook;
14
- exports.VERSION = '0.1.0';
15
+ exports.VERSION = '0.2.0';
15
16
  // ---------- errors ----------
16
17
  class GuidingHandError extends Error {
17
18
  status;
@@ -44,6 +45,7 @@ exports.PermissionDeniedError = PermissionDeniedError;
44
45
  class NotFoundError extends GuidingHandError {
45
46
  }
46
47
  exports.NotFoundError = NotFoundError;
48
+ /** 409. From respond(): extra.code is 'already_answered' when someone answered first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
47
49
  class ConflictError extends GuidingHandError {
48
50
  }
49
51
  exports.ConflictError = ConflictError;
@@ -62,7 +64,7 @@ exports.TimeoutError = TimeoutError;
62
64
  class SessionExpiredError extends GuidingHandError {
63
65
  }
64
66
  exports.SessionExpiredError = SessionExpiredError;
65
- /** A task waits on a question or approval and `run()` got no handler for it. */
67
+ /** A task waits on a question or approval and `run()` got no handler for it (a question the person at the computer can answer is left to them instead). */
66
68
  class NeedsInputError extends GuidingHandError {
67
69
  task;
68
70
  constructor(task) { super(`The task is waiting for ${task.pending?.type === 'approval' ? 'an approval' : 'an answer'}; pass ${task.pending?.type === 'approval' ? 'onApproval' : 'onQuestion'} to run().`, 0, 'needs_input'); this.task = task; }
@@ -123,6 +125,9 @@ class GuidingHand {
123
125
  }
124
126
  if (res.ok)
125
127
  return (raw ? new Uint8Array(await res.arrayBuffer()) : await res.json());
128
+ // A retried delete that finds it gone: the first attempt did it (its answer was lost on the way back).
129
+ if (method === 'DELETE' && attempt > 0 && res.status === 404)
130
+ return { object: path.split('/')[1]?.replace(/s$/, '') ?? 'object', deleted: true };
126
131
  if (canRetry && attempt < this.maxRetries && (res.status === 429 || res.status >= 500)) {
127
132
  const after = Number(res.headers.get('retry-after'));
128
133
  await sleep(Number.isFinite(after) && after > 0 ? after * 1000 : 500 * 2 ** attempt);
@@ -209,14 +214,16 @@ class Tasks extends Resource {
209
214
  return r.task;
210
215
  }
211
216
  }
212
- /** Starts a task and sees it through: events to onEvent, questions to onQuestion, approvals to onApproval. Resolves with the finished task. */
217
+ /** Starts a task and sees it through: events to onEvent, questions to onQuestion (or the person at the computer), approvals to onApproval. Resolves with the finished task. */
213
218
  async run(sessionId, { onEvent, onQuestion, onApproval, timeout, ...params }) {
214
219
  let task = await this.create(sessionId, params);
215
220
  const until = timeout ? Date.now() + timeout : Infinity;
216
221
  let after = 0, answered = '';
217
222
  while (!task.done) {
218
223
  if (Date.now() > until) {
219
- await this.stop(task.task_id).catch(() => { });
224
+ const stopped = await this.stop(task.task_id).catch(() => null);
225
+ if (stopped && stopped.done && !stopped.interrupted)
226
+ return stopped; // it finished just as time ran out
220
227
  throw new TimeoutError(`Task ${task.task_id} took longer than ${Math.round(timeout / 1000)} s; stopped it.`, 0, 'timeout');
221
228
  }
222
229
  const r = await this.events(task.task_id, { after, wait_ms: Math.min(25_000, Math.max(0, until - Date.now())) });
@@ -229,18 +236,32 @@ class Tasks extends Resource {
229
236
  continue;
230
237
  const id = p.type === 'question' ? p.question_id : p.approval_id;
231
238
  if (id === answered)
232
- continue; // answered already; the task is picking it up
233
- if (p.type === 'question') {
234
- if (!onQuestion)
235
- throw new NeedsInputError(task);
236
- await this.respond(task.task_id, { question_id: p.question_id, answer: String(await onQuestion(p, task)) });
239
+ continue; // answered already (or left to the person at the computer); the task is picking it up
240
+ try {
241
+ if (p.type === 'question') {
242
+ // Without onQuestion, or when it returns nothing, a question the person at the computer can answer on
243
+ // their screen is left to them: keep following.
244
+ if (!onQuestion && !p.customer_can_answer)
245
+ throw new NeedsInputError(task);
246
+ const reply = onQuestion ? await onQuestion(p, task) : null;
247
+ if (reply !== null && reply !== undefined)
248
+ await this.respond(task.task_id, { question_id: p.question_id, answer: String(reply) });
249
+ else if (!p.customer_can_answer)
250
+ throw new TypeError('onQuestion returned no answer, and the person at the computer can’t answer this question (customer_can_answer is false).');
251
+ }
252
+ else {
253
+ if (!onApproval)
254
+ throw new NeedsInputError(task);
255
+ const d = await onApproval(p, task);
256
+ const decision = d && typeof d === 'object' ? d : { decision: d === true || d === 'approve' ? 'approve' : 'deny' };
257
+ await this.respond(task.task_id, { approval_id: p.approval_id, ...decision });
258
+ }
237
259
  }
238
- else {
239
- if (!onApproval)
240
- throw new NeedsInputError(task);
241
- const d = await onApproval(p, task);
242
- const decision = typeof d === 'object' ? d : { decision: d === true || d === 'approve' ? 'approve' : 'deny' };
243
- await this.respond(task.task_id, { approval_id: p.approval_id, ...decision });
260
+ catch (e) {
261
+ // Answered somewhere else first (the person at the computer, the console, another process: extra.code
262
+ // 'already_answered') or the task ended meanwhile: keep following.
263
+ if (!(e instanceof ConflictError))
264
+ throw e;
244
265
  }
245
266
  answered = id;
246
267
  }
@@ -250,7 +271,11 @@ class Tasks extends Resource {
250
271
  // ---------- webhooks ----------
251
272
  class WebhookEndpoint extends Resource {
252
273
  retrieve() { return this.client.request('GET', '/webhook'); }
253
- /** Sets the endpoint. The signing secret is in the response when it's new (or with rotate_secret), never again. */
274
+ /**
275
+ * Sets the endpoint. Only the fields you pass change: without url the endpoint stays, without events the
276
+ * filter stays, so { rotate_secret: true } alone makes a new secret. The signing secret is in the response
277
+ * when it's new (or rotated), never again.
278
+ */
254
279
  update(params) { return this.client.request('PUT', '/webhook', { body: params }); }
255
280
  delete() { return this.client.request('PUT', '/webhook', { body: { url: null } }); }
256
281
  }
@@ -267,8 +292,10 @@ async function verifyWebhook(payload, signatureHeader, secret, { tolerance = 300
267
292
  if (Math.abs(Date.now() / 1000 - t) > tolerance)
268
293
  throw fail('The webhook’s timestamp is too old.');
269
294
  const body = typeof payload === 'string' ? payload : new TextDecoder().decode(payload);
270
- const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
271
- const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${body}`)));
295
+ // Node 18 has WebCrypto at node:crypto but not as a global; Node 20+, Deno, Bun and Workers have both.
296
+ const subtle = globalThis.crypto?.subtle ?? (await Promise.resolve(`${'node:crypto'}`).then(s => require(s))).webcrypto.subtle;
297
+ const key = await subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
298
+ const mac = new Uint8Array(await subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${body}`)));
272
299
  const want = [...mac].map((b) => b.toString(16).padStart(2, '0')).join('');
273
300
  if (want.length !== v1.length)
274
301
  throw fail('The webhook’s signature doesn’t match.');
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.1.0";
1
+ export declare const VERSION = "0.2.0";
2
2
  export type Metadata = Record<string, string>;
3
3
  export type Effort = 'low' | 'medium' | 'high' | null;
4
4
  export type Agent = {
@@ -8,6 +8,12 @@ export type Agent = {
8
8
  instructions: string;
9
9
  effort: Effort;
10
10
  greeting: string;
11
+ /** What the app calls the agent on the person's screen; '' is "GuidingHand". */
12
+ display_name: string;
13
+ /** The app shows the agent's thoughts and steps on the person's screen as it works. */
14
+ narration: boolean;
15
+ /** The person at the computer can answer the agent's questions in the app (the team still can; the first answer is used). Approvals always stay with the team. */
16
+ customer_answers: boolean;
11
17
  is_default: boolean;
12
18
  invite_url_template: string;
13
19
  created_at: string | null;
@@ -38,11 +44,13 @@ export type Session = {
38
44
  latest_task_id?: string | null;
39
45
  };
40
46
  export type TaskStatus = 'queued' | 'running' | 'waiting_for_user' | 'waiting_for_approval' | 'completed' | 'failed' | 'stopped';
47
+ /** customer_can_answer: the person at the computer can answer it on their screen too (the first answer is used). */
41
48
  export type Question = {
42
49
  type: 'question';
43
50
  question_id: string;
44
51
  question: string;
45
52
  options: string[];
53
+ customer_can_answer: boolean;
46
54
  };
47
55
  export type Approval = {
48
56
  type: 'approval';
@@ -51,6 +59,9 @@ export type Approval = {
51
59
  risk: 'low' | 'medium' | 'high';
52
60
  };
53
61
  export type EventType = 'started' | 'progress' | 'thinking' | 'action' | 'message' | 'question' | 'answer' | 'approval_required' | 'approved' | 'denied' | 'completed' | 'error' | 'stopped';
62
+ /** Who answered a question: the person at the computer ('customer', in the app) or your team ('operator': the API or the console). */
63
+ export type AnsweredBy = 'customer' | 'operator';
64
+ /** data: e.g. { action } for 'action', { question_id, question, options } for 'question', { question_id, answered_by } for 'answer' (whose message is the answer). */
54
65
  export type TaskEvent = {
55
66
  cursor: number;
56
67
  type: EventType;
@@ -96,7 +107,7 @@ export type Recording = {
96
107
  task_id: string;
97
108
  frames: Frame[];
98
109
  };
99
- export type WebhookEventType = 'session.connected' | 'session.disconnected' | 'task.started' | 'task.waiting_for_user' | 'task.waiting_for_approval' | 'task.completed' | 'task.failed' | 'task.stopped';
110
+ export type WebhookEventType = 'session.connected' | 'session.disconnected' | 'task.started' | 'task.waiting_for_user' | 'task.question_answered' | 'task.waiting_for_approval' | 'task.completed' | 'task.failed' | 'task.stopped';
100
111
  export type Webhook = {
101
112
  object: 'webhook';
102
113
  url: string | null;
@@ -105,6 +116,12 @@ export type Webhook = {
105
116
  secret?: string;
106
117
  event_types: WebhookEventType[];
107
118
  };
119
+ export type QuestionAnswer = {
120
+ question_id: string;
121
+ answer: string;
122
+ answered_by: AnsweredBy;
123
+ };
124
+ /** data.answer: for task.question_answered. */
108
125
  export type WebhookEvent = {
109
126
  id: string;
110
127
  type: WebhookEventType;
@@ -113,6 +130,7 @@ export type WebhookEvent = {
113
130
  data: {
114
131
  task?: Task;
115
132
  session?: Session;
133
+ answer?: QuestionAnswer;
116
134
  };
117
135
  };
118
136
  export type Page<T> = {
@@ -141,6 +159,7 @@ export declare class PermissionDeniedError extends GuidingHandError {
141
159
  }
142
160
  export declare class NotFoundError extends GuidingHandError {
143
161
  }
162
+ /** 409. From respond(): extra.code is 'already_answered' when someone answered first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
144
163
  export declare class ConflictError extends GuidingHandError {
145
164
  }
146
165
  export declare class RateLimitError extends GuidingHandError {
@@ -153,7 +172,7 @@ export declare class TimeoutError extends GuidingHandError {
153
172
  }
154
173
  export declare class SessionExpiredError extends GuidingHandError {
155
174
  }
156
- /** A task waits on a question or approval and `run()` got no handler for it. */
175
+ /** A task waits on a question or approval and `run()` got no handler for it (a question the person at the computer can answer is left to them instead). */
157
176
  export declare class NeedsInputError extends GuidingHandError {
158
177
  task: Task;
159
178
  constructor(task: Task);
@@ -205,6 +224,9 @@ export type AgentCreate = {
205
224
  instructions?: string;
206
225
  effort?: Effort;
207
226
  greeting?: string;
227
+ display_name?: string;
228
+ narration?: boolean;
229
+ customer_answers?: boolean;
208
230
  };
209
231
  export type AgentUpdate = Partial<Omit<AgentCreate, 'agent_id'>>;
210
232
  declare class Agents extends Resource {
@@ -264,8 +286,12 @@ export type Respond = {
264
286
  export type RunOptions = TaskCreate & {
265
287
  /** Every event, as it happens. */
266
288
  onEvent?: (event: TaskEvent, task: Task) => void | Promise<void>;
267
- /** The agent asks something: return the answer. */
268
- onQuestion?: (question: Question, task: Task) => string | Promise<string>;
289
+ /**
290
+ * The agent asks something: return the answer. When the person at the computer can answer it on their screen
291
+ * (question.customer_can_answer), return null to leave it to them; without onQuestion, run() does that for
292
+ * every such question and throws NeedsInputError only for the others. If they answer first, yours is dropped.
293
+ */
294
+ onQuestion?: (question: Question, task: Task) => string | null | undefined | Promise<string | null | undefined>;
269
295
  /** The agent wants to do something consequential: return true (or 'approve') to let it, false (or 'deny', or { decision, note }) not to. */
270
296
  onApproval?: (approval: Approval, task: Task) => boolean | 'approve' | 'deny' | {
271
297
  decision: 'approve' | 'deny';
@@ -305,14 +331,18 @@ declare class Tasks extends Resource {
305
331
  stream(taskId: string, { after }?: {
306
332
  after?: number;
307
333
  }): AsyncGenerator<TaskEvent, Task>;
308
- /** Starts a task and sees it through: events to onEvent, questions to onQuestion, approvals to onApproval. Resolves with the finished task. */
334
+ /** Starts a task and sees it through: events to onEvent, questions to onQuestion (or the person at the computer), approvals to onApproval. Resolves with the finished task. */
309
335
  run(sessionId: string, { onEvent, onQuestion, onApproval, timeout, ...params }: RunOptions): Promise<Task>;
310
336
  }
311
337
  declare class WebhookEndpoint extends Resource {
312
338
  retrieve(): Promise<Webhook>;
313
- /** Sets the endpoint. The signing secret is in the response when it's new (or with rotate_secret), never again. */
339
+ /**
340
+ * Sets the endpoint. Only the fields you pass change: without url the endpoint stays, without events the
341
+ * filter stays, so { rotate_secret: true } alone makes a new secret. The signing secret is in the response
342
+ * when it's new (or rotated), never again.
343
+ */
314
344
  update(params: {
315
- url: string;
345
+ url?: string;
316
346
  events?: WebhookEventType[];
317
347
  rotate_secret?: boolean;
318
348
  }): Promise<Webhook>;
package/dist/esm/index.js CHANGED
@@ -7,7 +7,8 @@
7
7
  // // send session.invite_url to the person at the computer, then:
8
8
  // await gh.sessions.waitForConnection(session.session_id);
9
9
  // const task = await gh.tasks.run(session.session_id, { prompt: 'Turn on Dark Mode', onQuestion: async (q) => 'Work', onApproval: async () => true });
10
- export const VERSION = '0.1.0';
10
+ // // (without onQuestion, questions the person at the computer can answer on their screen are left to them)
11
+ export const VERSION = '0.2.0';
11
12
  // ---------- errors ----------
12
13
  export class GuidingHandError extends Error {
13
14
  status;
@@ -34,6 +35,7 @@ export class PermissionDeniedError extends GuidingHandError {
34
35
  }
35
36
  export class NotFoundError extends GuidingHandError {
36
37
  }
38
+ /** 409. From respond(): extra.code is 'already_answered' when someone answered first (extra.answered_by: 'customer' is the person at the computer), else 'not_pending'. */
37
39
  export class ConflictError extends GuidingHandError {
38
40
  }
39
41
  export class RateLimitError extends GuidingHandError {
@@ -46,7 +48,7 @@ export class TimeoutError extends GuidingHandError {
46
48
  }
47
49
  export class SessionExpiredError extends GuidingHandError {
48
50
  }
49
- /** A task waits on a question or approval and `run()` got no handler for it. */
51
+ /** A task waits on a question or approval and `run()` got no handler for it (a question the person at the computer can answer is left to them instead). */
50
52
  export class NeedsInputError extends GuidingHandError {
51
53
  task;
52
54
  constructor(task) { super(`The task is waiting for ${task.pending?.type === 'approval' ? 'an approval' : 'an answer'}; pass ${task.pending?.type === 'approval' ? 'onApproval' : 'onQuestion'} to run().`, 0, 'needs_input'); this.task = task; }
@@ -105,6 +107,9 @@ export class GuidingHand {
105
107
  }
106
108
  if (res.ok)
107
109
  return (raw ? new Uint8Array(await res.arrayBuffer()) : await res.json());
110
+ // A retried delete that finds it gone: the first attempt did it (its answer was lost on the way back).
111
+ if (method === 'DELETE' && attempt > 0 && res.status === 404)
112
+ return { object: path.split('/')[1]?.replace(/s$/, '') ?? 'object', deleted: true };
108
113
  if (canRetry && attempt < this.maxRetries && (res.status === 429 || res.status >= 500)) {
109
114
  const after = Number(res.headers.get('retry-after'));
110
115
  await sleep(Number.isFinite(after) && after > 0 ? after * 1000 : 500 * 2 ** attempt);
@@ -190,14 +195,16 @@ class Tasks extends Resource {
190
195
  return r.task;
191
196
  }
192
197
  }
193
- /** Starts a task and sees it through: events to onEvent, questions to onQuestion, approvals to onApproval. Resolves with the finished task. */
198
+ /** Starts a task and sees it through: events to onEvent, questions to onQuestion (or the person at the computer), approvals to onApproval. Resolves with the finished task. */
194
199
  async run(sessionId, { onEvent, onQuestion, onApproval, timeout, ...params }) {
195
200
  let task = await this.create(sessionId, params);
196
201
  const until = timeout ? Date.now() + timeout : Infinity;
197
202
  let after = 0, answered = '';
198
203
  while (!task.done) {
199
204
  if (Date.now() > until) {
200
- await this.stop(task.task_id).catch(() => { });
205
+ const stopped = await this.stop(task.task_id).catch(() => null);
206
+ if (stopped && stopped.done && !stopped.interrupted)
207
+ return stopped; // it finished just as time ran out
201
208
  throw new TimeoutError(`Task ${task.task_id} took longer than ${Math.round(timeout / 1000)} s; stopped it.`, 0, 'timeout');
202
209
  }
203
210
  const r = await this.events(task.task_id, { after, wait_ms: Math.min(25_000, Math.max(0, until - Date.now())) });
@@ -210,18 +217,32 @@ class Tasks extends Resource {
210
217
  continue;
211
218
  const id = p.type === 'question' ? p.question_id : p.approval_id;
212
219
  if (id === answered)
213
- continue; // answered already; the task is picking it up
214
- if (p.type === 'question') {
215
- if (!onQuestion)
216
- throw new NeedsInputError(task);
217
- await this.respond(task.task_id, { question_id: p.question_id, answer: String(await onQuestion(p, task)) });
220
+ continue; // answered already (or left to the person at the computer); the task is picking it up
221
+ try {
222
+ if (p.type === 'question') {
223
+ // Without onQuestion, or when it returns nothing, a question the person at the computer can answer on
224
+ // their screen is left to them: keep following.
225
+ if (!onQuestion && !p.customer_can_answer)
226
+ throw new NeedsInputError(task);
227
+ const reply = onQuestion ? await onQuestion(p, task) : null;
228
+ if (reply !== null && reply !== undefined)
229
+ await this.respond(task.task_id, { question_id: p.question_id, answer: String(reply) });
230
+ else if (!p.customer_can_answer)
231
+ throw new TypeError('onQuestion returned no answer, and the person at the computer can’t answer this question (customer_can_answer is false).');
232
+ }
233
+ else {
234
+ if (!onApproval)
235
+ throw new NeedsInputError(task);
236
+ const d = await onApproval(p, task);
237
+ const decision = d && typeof d === 'object' ? d : { decision: d === true || d === 'approve' ? 'approve' : 'deny' };
238
+ await this.respond(task.task_id, { approval_id: p.approval_id, ...decision });
239
+ }
218
240
  }
219
- else {
220
- if (!onApproval)
221
- throw new NeedsInputError(task);
222
- const d = await onApproval(p, task);
223
- const decision = typeof d === 'object' ? d : { decision: d === true || d === 'approve' ? 'approve' : 'deny' };
224
- await this.respond(task.task_id, { approval_id: p.approval_id, ...decision });
241
+ catch (e) {
242
+ // Answered somewhere else first (the person at the computer, the console, another process: extra.code
243
+ // 'already_answered') or the task ended meanwhile: keep following.
244
+ if (!(e instanceof ConflictError))
245
+ throw e;
225
246
  }
226
247
  answered = id;
227
248
  }
@@ -231,7 +252,11 @@ class Tasks extends Resource {
231
252
  // ---------- webhooks ----------
232
253
  class WebhookEndpoint extends Resource {
233
254
  retrieve() { return this.client.request('GET', '/webhook'); }
234
- /** Sets the endpoint. The signing secret is in the response when it's new (or with rotate_secret), never again. */
255
+ /**
256
+ * Sets the endpoint. Only the fields you pass change: without url the endpoint stays, without events the
257
+ * filter stays, so { rotate_secret: true } alone makes a new secret. The signing secret is in the response
258
+ * when it's new (or rotated), never again.
259
+ */
235
260
  update(params) { return this.client.request('PUT', '/webhook', { body: params }); }
236
261
  delete() { return this.client.request('PUT', '/webhook', { body: { url: null } }); }
237
262
  }
@@ -248,8 +273,10 @@ export async function verifyWebhook(payload, signatureHeader, secret, { toleranc
248
273
  if (Math.abs(Date.now() / 1000 - t) > tolerance)
249
274
  throw fail('The webhook’s timestamp is too old.');
250
275
  const body = typeof payload === 'string' ? payload : new TextDecoder().decode(payload);
251
- const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
252
- const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${body}`)));
276
+ // Node 18 has WebCrypto at node:crypto but not as a global; Node 20+, Deno, Bun and Workers have both.
277
+ const subtle = globalThis.crypto?.subtle ?? (await import('node:crypto')).webcrypto.subtle;
278
+ const key = await subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
279
+ const mac = new Uint8Array(await subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${body}`)));
253
280
  const want = [...mac].map((b) => b.toString(16).padStart(2, '0')).join('');
254
281
  if (want.length !== v1.length)
255
282
  throw fail('The webhook’s signature doesn’t match.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guidinghand",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "GuidingHand SDK: put an AI agent on your customer's computer with one link. Sessions, tasks, agents, recordings and webhooks.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://guidinghand.ai",