guidinghand 0.1.1 → 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.1";
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,7 +331,7 @@ 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 {
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.1';
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; }
@@ -212,7 +214,7 @@ class Tasks extends Resource {
212
214
  return r.task;
213
215
  }
214
216
  }
215
- /** 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. */
216
218
  async run(sessionId, { onEvent, onQuestion, onApproval, timeout, ...params }) {
217
219
  let task = await this.create(sessionId, params);
218
220
  const until = timeout ? Date.now() + timeout : Infinity;
@@ -234,12 +236,18 @@ class Tasks extends Resource {
234
236
  continue;
235
237
  const id = p.type === 'question' ? p.question_id : p.approval_id;
236
238
  if (id === answered)
237
- continue; // answered already; the task is picking it up
239
+ continue; // answered already (or left to the person at the computer); the task is picking it up
238
240
  try {
239
241
  if (p.type === 'question') {
240
- if (!onQuestion)
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)
241
245
  throw new NeedsInputError(task);
242
- await this.respond(task.task_id, { question_id: p.question_id, answer: String(await onQuestion(p, 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).');
243
251
  }
244
252
  else {
245
253
  if (!onApproval)
@@ -250,7 +258,8 @@ class Tasks extends Resource {
250
258
  }
251
259
  }
252
260
  catch (e) {
253
- // Answered from somewhere else (the console, another process) or the task ended meanwhile: keep following.
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.
254
263
  if (!(e instanceof ConflictError))
255
264
  throw e;
256
265
  }
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.1.1";
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,7 +331,7 @@ 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 {
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.1';
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; }
@@ -193,7 +195,7 @@ class Tasks extends Resource {
193
195
  return r.task;
194
196
  }
195
197
  }
196
- /** 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. */
197
199
  async run(sessionId, { onEvent, onQuestion, onApproval, timeout, ...params }) {
198
200
  let task = await this.create(sessionId, params);
199
201
  const until = timeout ? Date.now() + timeout : Infinity;
@@ -215,12 +217,18 @@ class Tasks extends Resource {
215
217
  continue;
216
218
  const id = p.type === 'question' ? p.question_id : p.approval_id;
217
219
  if (id === answered)
218
- continue; // answered already; the task is picking it up
220
+ continue; // answered already (or left to the person at the computer); the task is picking it up
219
221
  try {
220
222
  if (p.type === 'question') {
221
- if (!onQuestion)
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)
222
226
  throw new NeedsInputError(task);
223
- await this.respond(task.task_id, { question_id: p.question_id, answer: String(await onQuestion(p, 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).');
224
232
  }
225
233
  else {
226
234
  if (!onApproval)
@@ -231,7 +239,8 @@ class Tasks extends Resource {
231
239
  }
232
240
  }
233
241
  catch (e) {
234
- // Answered from somewhere else (the console, another process) or the task ended meanwhile: keep following.
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.
235
244
  if (!(e instanceof ConflictError))
236
245
  throw e;
237
246
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guidinghand",
3
- "version": "0.1.1",
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",