opencode-claw 0.1.0 → 0.2.1

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
@@ -51,8 +51,7 @@ opencode-claw
51
51
 
52
52
  ```bash
53
53
  # 1. Create a config file in your project directory
54
- curl -O https://raw.githubusercontent.com/jinkoso/opencode-claw/main/opencode-claw.example.json
55
- mv opencode-claw.example.json opencode-claw.json
54
+ npx opencode-claw --init
56
55
 
57
56
  # 2. Edit opencode-claw.json with your tokens and preferences
58
57
  # (see Configuration section below)
@@ -61,6 +60,8 @@ mv opencode-claw.example.json opencode-claw.json
61
60
  npx opencode-claw
62
61
  ```
63
62
 
63
+ Or create `opencode-claw.json` manually — see the [example config](https://github.com/jinkoso/opencode-claw/blob/main/opencode-claw.example.json) for all available options.
64
+
64
65
  The service starts an OpenCode server, connects your configured channels, initializes the memory system, and begins listening for messages.
65
66
 
66
67
  ## Configuration
@@ -1,4 +1,4 @@
1
- import type { OpencodeClient } from "@opencode-ai/sdk";
1
+ import type { OpencodeClient } from "@opencode-ai/sdk/v2";
2
2
  import type { Config } from "../config/types.js";
3
3
  import type { SessionManager } from "../sessions/manager.js";
4
4
  import type { Logger } from "../utils/logger.js";
@@ -1,4 +1,5 @@
1
1
  import { buildSessionKey } from "../sessions/manager.js";
2
+ import { promptStreaming } from "../sessions/prompt.js";
2
3
  function allowlist(config, channel) {
3
4
  const ch = config.channels[channel];
4
5
  if (!ch)
@@ -17,16 +18,10 @@ function rejection(config, channel) {
17
18
  }
18
19
  function checkAllowlist(config, msg) {
19
20
  const list = allowlist(config, msg.channel);
20
- if (!list)
21
+ if (!list || list.length === 0)
21
22
  return true;
22
23
  return list.includes(msg.peerId);
23
24
  }
24
- function extractText(parts) {
25
- return parts
26
- .filter((p) => p.type === "text" && typeof p.text === "string")
27
- .map((p) => p.text)
28
- .join("\n\n");
29
- }
30
25
  function parseCommand(text) {
31
26
  const trimmed = text.trim();
32
27
  if (!trimmed.startsWith("/"))
@@ -42,8 +37,13 @@ const HELP_TEXT = `Available commands:
42
37
  /sessions — List your sessions
43
38
  /current — Show current session
44
39
  /fork — Fork current session into a new one
40
+ /cancel — Abort the currently running agent
45
41
  /help — Show this help`;
46
- async function handleCommand(cmd, msg, deps) {
42
+ // peerKey uniquely identifies a peer within a channel for active-stream tracking
43
+ function peerKey(channel, peerId) {
44
+ return `${channel}:${peerId}`;
45
+ }
46
+ async function handleCommand(cmd, msg, deps, activeStreams) {
47
47
  const key = buildSessionKey(msg.channel, msg.peerId, msg.threadId);
48
48
  const prefix = `${msg.channel}:${msg.peerId}`;
49
49
  switch (cmd.name) {
@@ -79,8 +79,7 @@ async function handleCommand(cmd, msg, deps) {
79
79
  if (!current)
80
80
  return "No active session to fork.";
81
81
  const result = await deps.client.session.fork({
82
- path: { id: current },
83
- body: {},
82
+ sessionID: current,
84
83
  });
85
84
  if (!result.data)
86
85
  return "Fork failed: no data returned.";
@@ -88,6 +87,16 @@ async function handleCommand(cmd, msg, deps) {
88
87
  await deps.sessions.switchSession(key, forked);
89
88
  return `Forked into new session: ${forked}`;
90
89
  }
90
+ case "cancel": {
91
+ const pk = peerKey(msg.channel, msg.peerId);
92
+ const sessionId = activeStreams.get(pk);
93
+ if (!sessionId)
94
+ return "No agent is currently running.";
95
+ const result = await deps.client.session.abort({ sessionID: sessionId });
96
+ const aborted = result.data ?? false;
97
+ deps.logger.info("router: session aborted by user", { sessionId, aborted });
98
+ return aborted ? "Agent aborted." : "Abort request sent (agent may already be done).";
99
+ }
91
100
  case "help": {
92
101
  return HELP_TEXT;
93
102
  }
@@ -96,7 +105,7 @@ async function handleCommand(cmd, msg, deps) {
96
105
  }
97
106
  }
98
107
  }
99
- async function routeMessage(msg, deps) {
108
+ async function routeMessage(msg, deps, activeStreams, pendingQuestions) {
100
109
  const adapter = deps.adapters.get(msg.channel);
101
110
  if (!adapter) {
102
111
  deps.logger.warn("router: no adapter for channel", { channel: msg.channel });
@@ -117,7 +126,7 @@ async function routeMessage(msg, deps) {
117
126
  // Command interception
118
127
  const cmd = parseCommand(msg.text);
119
128
  if (cmd) {
120
- const reply = await handleCommand(cmd, msg, deps);
129
+ const reply = await handleCommand(cmd, msg, deps, activeStreams);
121
130
  await adapter.send(msg.peerId, { text: reply, replyToId: msg.replyToId });
122
131
  return;
123
132
  }
@@ -125,38 +134,91 @@ async function routeMessage(msg, deps) {
125
134
  const key = buildSessionKey(msg.channel, msg.peerId, msg.threadId);
126
135
  const sessionId = await deps.sessions.resolveSession(key);
127
136
  deps.logger.debug("router: prompting session", { sessionId, channel: msg.channel });
128
- const controller = new AbortController();
129
- const timer = setTimeout(() => controller.abort(), deps.timeoutMs);
130
- let result;
131
- try {
132
- result = await deps.client.session.prompt({
133
- path: { id: sessionId },
134
- body: { parts: [{ type: "text", text: msg.text }] },
137
+ const pk = peerKey(msg.channel, msg.peerId);
138
+ activeStreams.set(pk, sessionId);
139
+ // Start typing indicator
140
+ if (adapter.sendTyping) {
141
+ await adapter.sendTyping(msg.peerId).catch(() => { });
142
+ }
143
+ const progressEnabled = deps.config.router.progress.enabled;
144
+ function formatQuestion(request) {
145
+ const lines = ["❓ The agent has a question:"];
146
+ for (const q of request.questions) {
147
+ lines.push("");
148
+ if (q.header)
149
+ lines.push(`**${q.header}**`);
150
+ lines.push(q.question);
151
+ if (q.options && q.options.length > 0) {
152
+ for (let i = 0; i < q.options.length; i++) {
153
+ const opt = q.options[i];
154
+ if (opt) {
155
+ lines.push(` ${i + 1}. ${opt.label}${opt.description ? ` — ${opt.description}` : ""}`);
156
+ }
157
+ }
158
+ }
159
+ if (q.multiple)
160
+ lines.push("(You can pick multiple — separate with commas)");
161
+ }
162
+ lines.push("");
163
+ lines.push("Reply with your answer:");
164
+ return lines.join("\n");
165
+ }
166
+ function waitForUserReply(questionTimeoutMs) {
167
+ return new Promise((resolve, reject) => {
168
+ const timer = setTimeout(() => {
169
+ pendingQuestions.delete(pk);
170
+ reject(new Error("question_timeout"));
171
+ }, questionTimeoutMs);
172
+ pendingQuestions.set(pk, { resolve, timeout: timer });
135
173
  });
136
174
  }
175
+ const progress = progressEnabled
176
+ ? {
177
+ onToolRunning: (_tool, title) => adapter.send(msg.peerId, {
178
+ text: `🔧 ${title}...`,
179
+ replyToId: msg.replyToId,
180
+ }),
181
+ onHeartbeat: async () => {
182
+ if (adapter.sendTyping) {
183
+ await adapter.sendTyping(msg.peerId).catch(() => { });
184
+ }
185
+ await adapter.send(msg.peerId, { text: "⏳ Still working..." });
186
+ },
187
+ onQuestion: async (request) => {
188
+ const text = formatQuestion(request);
189
+ await adapter.send(msg.peerId, { text });
190
+ const userReply = await waitForUserReply(deps.timeoutMs);
191
+ return request.questions.map(() => [userReply]);
192
+ },
193
+ toolThrottleMs: deps.config.router.progress.toolThrottleMs,
194
+ heartbeatMs: deps.config.router.progress.heartbeatMs,
195
+ }
196
+ : undefined;
197
+ let reply;
198
+ try {
199
+ reply = await promptStreaming(deps.client, sessionId, msg.text, deps.timeoutMs, deps.logger, progress);
200
+ }
137
201
  catch (err) {
138
- clearTimeout(timer);
139
- if (controller.signal.aborted) {
140
- deps.logger.warn("router: session prompt timed out", {
141
- sessionId,
142
- timeoutMs: deps.timeoutMs,
143
- });
202
+ if (err instanceof Error && err.message === "timeout") {
144
203
  await adapter.send(msg.peerId, {
145
204
  text: "Request timed out. The agent took too long to respond.",
146
205
  replyToId: msg.replyToId,
147
206
  });
148
207
  return;
149
208
  }
209
+ if (err instanceof Error && err.message === "aborted") {
210
+ // Already notified via /cancel reply; nothing more to send
211
+ return;
212
+ }
150
213
  throw err;
151
214
  }
152
- clearTimeout(timer);
153
- if (!result.data) {
154
- deps.logger.error("router: prompt returned no data", { sessionId });
155
- await adapter.send(msg.peerId, { text: "Error: no response from agent." });
156
- return;
215
+ finally {
216
+ activeStreams.delete(pk);
217
+ pendingQuestions.delete(pk);
218
+ if (adapter.stopTyping) {
219
+ await adapter.stopTyping(msg.peerId).catch(() => { });
220
+ }
157
221
  }
158
- // Extract text parts from response
159
- const reply = extractText(result.data.parts);
160
222
  if (!reply) {
161
223
  deps.logger.warn("router: empty response from agent", { sessionId });
162
224
  await adapter.send(msg.peerId, { text: "(empty response)" });
@@ -165,9 +227,22 @@ async function routeMessage(msg, deps) {
165
227
  await adapter.send(msg.peerId, { text: reply, replyToId: msg.replyToId });
166
228
  }
167
229
  export function createRouter(deps) {
230
+ // Tracks which sessionId is currently streaming for each channel:peerId pair
231
+ const activeStreams = new Map();
232
+ // Tracks pending question resolvers — when agent asks a question, user's next message resolves it
233
+ const pendingQuestions = new Map();
168
234
  async function handler(msg) {
169
235
  try {
170
- await routeMessage(msg, deps);
236
+ // Check if this message is a reply to a pending question
237
+ const pk = peerKey(msg.channel, msg.peerId);
238
+ const pending = pendingQuestions.get(pk);
239
+ if (pending) {
240
+ clearTimeout(pending.timeout);
241
+ pendingQuestions.delete(pk);
242
+ pending.resolve(msg.text);
243
+ return;
244
+ }
245
+ await routeMessage(msg, deps, activeStreams, pendingQuestions);
171
246
  }
172
247
  catch (err) {
173
248
  deps.logger.error("router: unhandled error", {
@@ -80,6 +80,9 @@ export function createSlackAdapter(config, logger) {
80
80
  thread_ts: message.threadId,
81
81
  });
82
82
  },
83
+ async sendTyping(_peerId) {
84
+ // Slack has no general bot typing indicator API
85
+ },
83
86
  status() {
84
87
  return state;
85
88
  },
@@ -72,6 +72,9 @@ export function createTelegramAdapter(config, logger) {
72
72
  reply_parameters: message.replyToId ? { message_id: Number(message.replyToId) } : undefined,
73
73
  });
74
74
  },
75
+ async sendTyping(peerId) {
76
+ await bot.api.sendChatAction(Number(peerId), "typing");
77
+ },
75
78
  status() {
76
79
  return state;
77
80
  },
@@ -23,5 +23,7 @@ export type ChannelAdapter = {
23
23
  start(handler: InboundMessageHandler): Promise<void>;
24
24
  stop(): Promise<void>;
25
25
  send(peerId: string, message: OutboundMessage): Promise<void>;
26
+ sendTyping?(peerId: string): Promise<void>;
27
+ stopTyping?(peerId: string): Promise<void>;
26
28
  status(): ChannelStatus;
27
29
  };
@@ -129,6 +129,18 @@ export function createWhatsAppAdapter(config, logger) {
129
129
  const jid = `${peerId}@s.whatsapp.net`;
130
130
  await sock.sendMessage(jid, { text: message.text });
131
131
  },
132
+ async sendTyping(peerId) {
133
+ if (!sock)
134
+ return;
135
+ const jid = `${peerId}@s.whatsapp.net`;
136
+ await sock.sendPresenceUpdate("composing", jid);
137
+ },
138
+ async stopTyping(peerId) {
139
+ if (!sock)
140
+ return;
141
+ const jid = `${peerId}@s.whatsapp.net`;
142
+ await sock.sendPresenceUpdate("paused", jid);
143
+ },
132
144
  status() {
133
145
  return state;
134
146
  },
package/dist/cli.js CHANGED
@@ -1,6 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import { main } from "./index.js";
3
- main().catch((err) => {
4
- console.error("Fatal:", err);
5
- process.exit(1);
6
- });
3
+ const args = process.argv.slice(2);
4
+ if (args.includes("--init")) {
5
+ const { runOnboardingWizard } = await import("./wizard/onboarding.js");
6
+ const { createClackPrompter } = await import("./wizard/clack-prompter.js");
7
+ const { WizardCancelledError } = await import("./wizard/prompts.js");
8
+ const prompter = createClackPrompter();
9
+ try {
10
+ await runOnboardingWizard(prompter);
11
+ }
12
+ catch (err) {
13
+ if (err instanceof WizardCancelledError) {
14
+ process.exit(1);
15
+ }
16
+ throw err;
17
+ }
18
+ }
19
+ else {
20
+ main().catch((err) => {
21
+ console.error("Fatal:", err);
22
+ process.exit(1);
23
+ });
24
+ }
@@ -310,10 +310,33 @@ export declare const configSchema: z.ZodObject<{
310
310
  }>>;
311
311
  router: z.ZodDefault<z.ZodObject<{
312
312
  timeoutMs: z.ZodDefault<z.ZodNumber>;
313
+ progress: z.ZodDefault<z.ZodObject<{
314
+ enabled: z.ZodDefault<z.ZodBoolean>;
315
+ toolThrottleMs: z.ZodDefault<z.ZodNumber>;
316
+ heartbeatMs: z.ZodDefault<z.ZodNumber>;
317
+ }, "strip", z.ZodTypeAny, {
318
+ enabled: boolean;
319
+ toolThrottleMs: number;
320
+ heartbeatMs: number;
321
+ }, {
322
+ enabled?: boolean | undefined;
323
+ toolThrottleMs?: number | undefined;
324
+ heartbeatMs?: number | undefined;
325
+ }>>;
313
326
  }, "strip", z.ZodTypeAny, {
314
327
  timeoutMs: number;
328
+ progress: {
329
+ enabled: boolean;
330
+ toolThrottleMs: number;
331
+ heartbeatMs: number;
332
+ };
315
333
  }, {
316
334
  timeoutMs?: number | undefined;
335
+ progress?: {
336
+ enabled?: boolean | undefined;
337
+ toolThrottleMs?: number | undefined;
338
+ heartbeatMs?: number | undefined;
339
+ } | undefined;
317
340
  }>>;
318
341
  }, "strip", z.ZodTypeAny, {
319
342
  opencode: {
@@ -375,6 +398,11 @@ export declare const configSchema: z.ZodObject<{
375
398
  };
376
399
  router: {
377
400
  timeoutMs: number;
401
+ progress: {
402
+ enabled: boolean;
403
+ toolThrottleMs: number;
404
+ heartbeatMs: number;
405
+ };
378
406
  };
379
407
  cron?: {
380
408
  enabled: boolean;
@@ -478,5 +506,10 @@ export declare const configSchema: z.ZodObject<{
478
506
  } | undefined;
479
507
  router?: {
480
508
  timeoutMs?: number | undefined;
509
+ progress?: {
510
+ enabled?: boolean | undefined;
511
+ toolThrottleMs?: number | undefined;
512
+ heartbeatMs?: number | undefined;
513
+ } | undefined;
481
514
  } | undefined;
482
515
  }>;
@@ -103,6 +103,13 @@ export const configSchema = z.object({
103
103
  router: z
104
104
  .object({
105
105
  timeoutMs: z.number().int().min(1000).default(300_000),
106
+ progress: z
107
+ .object({
108
+ enabled: z.boolean().default(true),
109
+ toolThrottleMs: z.number().int().min(1000).default(5_000),
110
+ heartbeatMs: z.number().int().min(10_000).default(60_000),
111
+ })
112
+ .default({}),
106
113
  })
107
114
  .default({}),
108
115
  });
@@ -1,4 +1,4 @@
1
- import type { OpencodeClient } from "@opencode-ai/sdk";
1
+ import type { OpencodeClient } from "@opencode-ai/sdk/v2";
2
2
  import type { CronConfig } from "../config/types.js";
3
3
  import type { OutboxWriter } from "../outbox/writer.js";
4
4
  import type { Logger } from "../utils/logger.js";
@@ -1,10 +1,5 @@
1
1
  import cron from "node-cron";
2
- function extractText(parts) {
3
- return parts
4
- .filter((p) => p.type === "text" && typeof p.text === "string")
5
- .map((p) => p.text)
6
- .join("\n\n");
7
- }
2
+ import { promptStreaming } from "../sessions/prompt.js";
8
3
  export function createCronScheduler(deps) {
9
4
  const jobs = new Map();
10
5
  const running = new Set();
@@ -18,39 +13,24 @@ export function createCronScheduler(deps) {
18
13
  deps.logger.info(`cron: firing job "${job.id}"`, { schedule: job.schedule });
19
14
  try {
20
15
  const session = await deps.client.session.create({
21
- body: { title },
16
+ title,
22
17
  });
23
18
  if (!session.data)
24
19
  throw new Error("session.create returned no data");
25
20
  const sessionId = session.data.id;
26
21
  deps.logger.debug(`cron: job "${job.id}" session created`, { sessionId });
27
- // session.prompt() is synchronous — blocks until the agent finishes.
28
- // Wrap with AbortSignal timeout for safety.
29
22
  const timeout = job.timeoutMs ?? deps.config.defaultTimeoutMs;
30
- const controller = new AbortController();
31
- const timer = setTimeout(() => controller.abort(), timeout);
32
- let result;
23
+ let text;
33
24
  try {
34
- result = await deps.client.session.prompt({
35
- path: { id: sessionId },
36
- body: { parts: [{ type: "text", text: job.prompt }] },
37
- });
25
+ text = await promptStreaming(deps.client, sessionId, job.prompt, timeout, deps.logger);
38
26
  }
39
27
  catch (err) {
40
- if (controller.signal.aborted) {
28
+ if (err instanceof Error && err.message === "timeout") {
41
29
  deps.logger.warn(`cron: job "${job.id}" timed out after ${timeout}ms`);
42
30
  return;
43
31
  }
44
32
  throw err;
45
33
  }
46
- finally {
47
- clearTimeout(timer);
48
- }
49
- if (!result.data) {
50
- deps.logger.warn(`cron: job "${job.id}" returned no data`);
51
- return;
52
- }
53
- const text = extractText(result.data.parts);
54
34
  deps.logger.info(`cron: job "${job.id}" completed`, {
55
35
  sessionId,
56
36
  responseLength: text.length,
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { dirname, resolve } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { createOpencode } from "@opencode-ai/sdk";
3
+ import { createOpencode } from "@opencode-ai/sdk/v2";
4
4
  import { createRouter } from "./channels/router.js";
5
5
  import { createSlackAdapter } from "./channels/slack.js";
6
6
  import { createTelegramAdapter } from "./channels/telegram.js";
@@ -1,4 +1,4 @@
1
- import type { OpencodeClient } from "@opencode-ai/sdk";
1
+ import type { OpencodeClient } from "@opencode-ai/sdk/v2";
2
2
  import type { SessionsConfig } from "../config/types.js";
3
3
  import type { Logger } from "../utils/logger.js";
4
4
  export type SessionInfo = {
@@ -14,7 +14,7 @@ export function createSessionManager(client, config, map, logger) {
14
14
  if (existing)
15
15
  return existing;
16
16
  const session = await client.session.create({
17
- body: { title: title ?? key },
17
+ title: title ?? key,
18
18
  });
19
19
  if (!session.data)
20
20
  throw new Error("session.create returned no data");
@@ -30,7 +30,7 @@ export function createSessionManager(client, config, map, logger) {
30
30
  }
31
31
  async function newSession(key, title) {
32
32
  const session = await client.session.create({
33
- body: { title: title ?? `New session ${new Date().toISOString()}` },
33
+ title: title ?? `New session ${new Date().toISOString()}`,
34
34
  });
35
35
  if (!session.data)
36
36
  throw new Error("session.create returned no data");
@@ -0,0 +1,13 @@
1
+ import type { OpencodeClient, QuestionRequest } from "@opencode-ai/sdk/v2";
2
+ import type { Logger } from "../utils/logger.js";
3
+ export type ToolProgressCallback = (tool: string, title: string) => Promise<void>;
4
+ export type HeartbeatCallback = () => Promise<void>;
5
+ export type QuestionCallback = (question: QuestionRequest) => Promise<Array<Array<string>>>;
6
+ export type ProgressOptions = {
7
+ onToolRunning?: ToolProgressCallback;
8
+ onHeartbeat?: HeartbeatCallback;
9
+ onQuestion?: QuestionCallback;
10
+ toolThrottleMs?: number;
11
+ heartbeatMs?: number;
12
+ };
13
+ export declare function promptStreaming(client: OpencodeClient, sessionId: string, promptText: string, timeoutMs: number, logger: Logger, progress?: ProgressOptions): Promise<string>;
@@ -0,0 +1,118 @@
1
+ export async function promptStreaming(client, sessionId, promptText, timeoutMs, logger, progress) {
2
+ const { stream } = await client.event.subscribe();
3
+ const controller = new AbortController();
4
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
5
+ const textParts = new Map();
6
+ const notifiedTools = new Set();
7
+ let lastToolNotifyTime = 0;
8
+ let lastActivityTime = Date.now();
9
+ let heartbeatTimer;
10
+ const toolThrottleMs = progress?.toolThrottleMs ?? 5_000;
11
+ const heartbeatMs = progress?.heartbeatMs ?? 60_000;
12
+ if (progress?.onHeartbeat && heartbeatMs > 0) {
13
+ const onHeartbeat = progress.onHeartbeat;
14
+ heartbeatTimer = setInterval(() => {
15
+ const elapsed = Date.now() - lastActivityTime;
16
+ if (elapsed >= heartbeatMs) {
17
+ onHeartbeat().catch(() => { });
18
+ lastActivityTime = Date.now();
19
+ }
20
+ }, heartbeatMs);
21
+ }
22
+ function touchActivity() {
23
+ lastActivityTime = Date.now();
24
+ }
25
+ try {
26
+ await client.session.promptAsync({
27
+ sessionID: sessionId,
28
+ parts: [{ type: "text", text: promptText }],
29
+ });
30
+ for await (const raw of stream) {
31
+ if (controller.signal.aborted) {
32
+ throw new Error("timeout");
33
+ }
34
+ const event = raw;
35
+ if (event.type === "message.part.delta") {
36
+ const { sessionID, partID, delta } = event.properties;
37
+ if (sessionID !== sessionId)
38
+ continue;
39
+ const prev = textParts.get(partID) ?? "";
40
+ textParts.set(partID, prev + delta);
41
+ continue;
42
+ }
43
+ if (event.type === "message.part.updated") {
44
+ const { part } = event.properties;
45
+ if (part.sessionID !== sessionId)
46
+ continue;
47
+ if (part.type === "text" && part.text) {
48
+ textParts.set(part.id, part.text);
49
+ }
50
+ if (part.type === "tool" && part.state.status === "running" && progress?.onToolRunning) {
51
+ const now = Date.now();
52
+ if (!notifiedTools.has(part.callID) && now - lastToolNotifyTime >= toolThrottleMs) {
53
+ notifiedTools.add(part.callID);
54
+ lastToolNotifyTime = now;
55
+ const title = "title" in part.state && part.state.title ? part.state.title : part.tool;
56
+ await progress.onToolRunning(part.tool, title).catch(() => { });
57
+ touchActivity();
58
+ }
59
+ }
60
+ continue;
61
+ }
62
+ if (event.type === "question.asked") {
63
+ const request = event.properties;
64
+ if (request.sessionID !== sessionId)
65
+ continue;
66
+ if (progress?.onQuestion) {
67
+ try {
68
+ const answers = await progress.onQuestion(request);
69
+ await client.question.reply({
70
+ requestID: request.id,
71
+ answers,
72
+ });
73
+ touchActivity();
74
+ }
75
+ catch {
76
+ await client.question.reject({ requestID: request.id });
77
+ }
78
+ }
79
+ else {
80
+ await client.question.reject({ requestID: request.id });
81
+ }
82
+ continue;
83
+ }
84
+ if (event.type === "session.error") {
85
+ const { sessionID, error } = event.properties;
86
+ if (sessionID && sessionID !== sessionId)
87
+ continue;
88
+ if (error && "name" in error && error.name === "MessageAbortedError") {
89
+ throw new Error("aborted");
90
+ }
91
+ const msg = error && "data" in error && typeof error.data.message === "string"
92
+ ? error.data.message
93
+ : "unknown session error";
94
+ throw new Error(msg);
95
+ }
96
+ if (event.type === "session.idle") {
97
+ if (event.properties.sessionID !== sessionId)
98
+ continue;
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ catch (err) {
104
+ if (controller.signal.aborted || (err instanceof Error && err.message === "timeout")) {
105
+ logger.warn("prompt: session timed out", { sessionId, timeoutMs });
106
+ throw new Error("timeout");
107
+ }
108
+ throw err;
109
+ }
110
+ finally {
111
+ clearTimeout(timer);
112
+ if (heartbeatTimer)
113
+ clearInterval(heartbeatTimer);
114
+ await stream.return(undefined);
115
+ }
116
+ const parts = [...textParts.values()];
117
+ return parts.join("");
118
+ }
@@ -0,0 +1,2 @@
1
+ import type { WizardPrompter } from "./prompts.js";
2
+ export declare function createClackPrompter(): WizardPrompter;
@@ -0,0 +1,53 @@
1
+ import { cancel, confirm, intro, isCancel, note, outro, select, text } from "@clack/prompts";
2
+ import { WizardCancelledError } from "./prompts.js";
3
+ function guardCancel(value) {
4
+ if (isCancel(value)) {
5
+ cancel("Setup cancelled.");
6
+ throw new WizardCancelledError();
7
+ }
8
+ return value;
9
+ }
10
+ export function createClackPrompter() {
11
+ return {
12
+ async intro(title) {
13
+ intro(title);
14
+ },
15
+ async outro(message) {
16
+ outro(message);
17
+ },
18
+ async note(message, title) {
19
+ note(message, title);
20
+ },
21
+ async select(params) {
22
+ const result = await select({
23
+ message: params.message,
24
+ options: params.options,
25
+ initialValue: params.initialValue,
26
+ });
27
+ return guardCancel(result);
28
+ },
29
+ async text(params) {
30
+ const result = await text({
31
+ message: params.message,
32
+ initialValue: params.initialValue,
33
+ placeholder: params.placeholder,
34
+ validate: params.validate
35
+ ? (value) => {
36
+ const fn = params.validate;
37
+ if (!fn)
38
+ return undefined;
39
+ return fn(value ?? "");
40
+ }
41
+ : undefined,
42
+ });
43
+ return guardCancel(result);
44
+ },
45
+ async confirm(params) {
46
+ const result = await confirm({
47
+ message: params.message,
48
+ initialValue: params.initialValue,
49
+ });
50
+ return guardCancel(result);
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,2 @@
1
+ import type { WizardPrompter } from "./prompts.js";
2
+ export declare function runOnboardingWizard(p: WizardPrompter): Promise<void>;
@@ -0,0 +1,209 @@
1
+ import { access, writeFile } from "node:fs/promises";
2
+ const ALL_CAPS_RE = /^[A-Z][A-Z0-9_]*$/;
3
+ function resolveTokenValue(input) {
4
+ const trimmed = input.trim();
5
+ if (trimmed.startsWith("${") && trimmed.endsWith("}"))
6
+ return trimmed;
7
+ if (trimmed.startsWith("$"))
8
+ return `\${${trimmed.slice(1)}}`;
9
+ if (ALL_CAPS_RE.test(trimmed))
10
+ return `\${${trimmed}}`;
11
+ return trimmed;
12
+ }
13
+ function splitAllowlist(raw) {
14
+ return raw
15
+ .split(",")
16
+ .map((s) => s.trim())
17
+ .filter((s) => s.length > 0);
18
+ }
19
+ async function collectTelegramConfig(p) {
20
+ const rawToken = await p.text({
21
+ message: "Telegram bot token (paste value or env var name like TELEGRAM_BOT_TOKEN):",
22
+ placeholder: "TELEGRAM_BOT_TOKEN",
23
+ validate: (v) => (v.trim().length === 0 ? "Required" : undefined),
24
+ });
25
+ const botToken = resolveTokenValue(rawToken);
26
+ const rawAllowlist = await p.text({
27
+ message: "Allowed Telegram usernames (comma-separated, leave blank for none):",
28
+ placeholder: "alice,bob",
29
+ });
30
+ return {
31
+ enabled: true,
32
+ botToken,
33
+ allowlist: splitAllowlist(rawAllowlist),
34
+ mode: "polling",
35
+ rejectionBehavior: "ignore",
36
+ };
37
+ }
38
+ async function collectSlackConfig(p) {
39
+ const rawBotToken = await p.text({
40
+ message: "Slack bot token (xoxb-... or env var name like SLACK_BOT_TOKEN):",
41
+ placeholder: "SLACK_BOT_TOKEN",
42
+ validate: (v) => (v.trim().length === 0 ? "Required" : undefined),
43
+ });
44
+ const botToken = resolveTokenValue(rawBotToken);
45
+ const rawAppToken = await p.text({
46
+ message: "Slack app token (xapp-... or env var name like SLACK_APP_TOKEN):",
47
+ placeholder: "SLACK_APP_TOKEN",
48
+ validate: (v) => (v.trim().length === 0 ? "Required" : undefined),
49
+ });
50
+ const appToken = resolveTokenValue(rawAppToken);
51
+ return {
52
+ enabled: true,
53
+ botToken,
54
+ appToken,
55
+ mode: "socket",
56
+ rejectionBehavior: "ignore",
57
+ };
58
+ }
59
+ async function collectWhatsAppConfig(p) {
60
+ const rawAllowlist = await p.text({
61
+ message: "Allowed phone numbers (comma-separated with country code, e.g. 5511999887766):",
62
+ placeholder: "5511999887766,441234567890",
63
+ validate: (v) => (v.trim().length === 0 ? "Required" : undefined),
64
+ });
65
+ return {
66
+ enabled: true,
67
+ allowlist: splitAllowlist(rawAllowlist),
68
+ authDir: "./data/whatsapp/auth",
69
+ debounceMs: 1000,
70
+ rejectionBehavior: "ignore",
71
+ };
72
+ }
73
+ async function collectChannels(p) {
74
+ const channels = {};
75
+ const configured = new Set();
76
+ const channelOptions = [
77
+ { value: "telegram", label: "Telegram", hint: "requires bot token" },
78
+ { value: "slack", label: "Slack", hint: "requires bot + app token" },
79
+ { value: "whatsapp", label: "WhatsApp", hint: "scan QR on first run" },
80
+ { value: "skip", label: "Skip — no channels now" },
81
+ ];
82
+ let configureMore = true;
83
+ while (configureMore) {
84
+ const available = channelOptions.filter((o) => o.value === "skip" || !configured.has(o.value));
85
+ const choice = await p.select({
86
+ message: "Which channel would you like to configure?",
87
+ options: available,
88
+ });
89
+ if (choice === "skip")
90
+ break;
91
+ const channelId = choice;
92
+ if (channelId === "telegram") {
93
+ channels.telegram = await collectTelegramConfig(p);
94
+ configured.add("telegram");
95
+ }
96
+ else if (channelId === "slack") {
97
+ channels.slack = await collectSlackConfig(p);
98
+ configured.add("slack");
99
+ }
100
+ else if (channelId === "whatsapp") {
101
+ channels.whatsapp = await collectWhatsAppConfig(p);
102
+ configured.add("whatsapp");
103
+ }
104
+ const allChannels = ["telegram", "slack", "whatsapp"];
105
+ const remaining = allChannels.filter((c) => !configured.has(c));
106
+ if (remaining.length === 0)
107
+ break;
108
+ configureMore = await p.confirm({
109
+ message: "Configure another channel?",
110
+ initialValue: false,
111
+ });
112
+ }
113
+ return channels;
114
+ }
115
+ async function collectMemoryConfig(p) {
116
+ const backend = await p.select({
117
+ message: "Memory backend:",
118
+ options: [
119
+ { value: "txt", label: "Text files (simple, zero deps)" },
120
+ { value: "openviking", label: "OpenViking (semantic search)" },
121
+ ],
122
+ initialValue: "txt",
123
+ });
124
+ if (backend === "openviking") {
125
+ const url = await p.text({
126
+ message: "OpenViking URL:",
127
+ initialValue: "http://localhost:8100",
128
+ validate: (v) => {
129
+ try {
130
+ new URL(v);
131
+ return undefined;
132
+ }
133
+ catch {
134
+ return "Must be a valid URL";
135
+ }
136
+ },
137
+ });
138
+ const fallback = await p.confirm({
139
+ message: "Fall back to text files if OpenViking is unreachable?",
140
+ initialValue: true,
141
+ });
142
+ return { backend: "openviking", openviking: { url, fallback } };
143
+ }
144
+ return { backend: "txt" };
145
+ }
146
+ export async function runOnboardingWizard(p) {
147
+ const configPath = "./opencode-claw.json";
148
+ let existingConfigFound = false;
149
+ try {
150
+ await access(configPath);
151
+ existingConfigFound = true;
152
+ }
153
+ catch {
154
+ // file doesn't exist — proceed
155
+ }
156
+ if (existingConfigFound) {
157
+ const overwrite = await p.confirm({
158
+ message: "opencode-claw.json already exists. Overwrite it?",
159
+ initialValue: false,
160
+ });
161
+ if (!overwrite) {
162
+ await p.outro("Setup cancelled. Existing config unchanged.");
163
+ return;
164
+ }
165
+ }
166
+ await p.intro("opencode-claw setup");
167
+ const channels = await collectChannels(p);
168
+ const memory = await collectMemoryConfig(p);
169
+ const portRaw = await p.text({
170
+ message: "OpenCode server port (0 = random):",
171
+ initialValue: "0",
172
+ validate: (v) => {
173
+ const n = Number(v);
174
+ if (!Number.isInteger(n) || n < 0 || n > 65535)
175
+ return "Must be an integer 0–65535";
176
+ return undefined;
177
+ },
178
+ });
179
+ const port = Number(portRaw);
180
+ const enableCron = await p.confirm({
181
+ message: "Enable cron job scheduling?",
182
+ initialValue: false,
183
+ });
184
+ if (enableCron) {
185
+ await p.note("Add jobs to the config file manually after setup.\nSee opencode-claw.example.json for the cron job schema.", "Cron jobs");
186
+ }
187
+ const channelSummary = Object.keys(channels).length === 0
188
+ ? " No channels configured"
189
+ : Object.keys(channels)
190
+ .map((c) => ` - ${c}`)
191
+ .join("\n");
192
+ const memorySummary = memory.backend === "openviking"
193
+ ? ` openviking (${memory.openviking.url})`
194
+ : " txt (./data/memory)";
195
+ await p.note([
196
+ `Channels:\n${channelSummary}`,
197
+ `Memory: ${memorySummary}`,
198
+ `OpenCode port: ${port}`,
199
+ `Cron: ${enableCron ? "enabled" : "disabled"}`,
200
+ ].join("\n"), "Config summary");
201
+ const config = {
202
+ opencode: { port },
203
+ memory,
204
+ channels,
205
+ ...(enableCron ? { cron: { enabled: true, defaultTimeoutMs: 300000, jobs: [] } } : {}),
206
+ };
207
+ await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
208
+ await p.outro("Config written to opencode-claw.json. Run 'npx opencode-claw' to start.");
209
+ }
@@ -0,0 +1,31 @@
1
+ export type WizardSelectOption<T = string> = {
2
+ value: T;
3
+ label: string;
4
+ hint?: string;
5
+ };
6
+ export type WizardSelectParams<T = string> = {
7
+ message: string;
8
+ options: Array<WizardSelectOption<T>>;
9
+ initialValue?: T;
10
+ };
11
+ export type WizardTextParams = {
12
+ message: string;
13
+ initialValue?: string;
14
+ placeholder?: string;
15
+ validate?: (value: string) => string | undefined;
16
+ };
17
+ export type WizardConfirmParams = {
18
+ message: string;
19
+ initialValue?: boolean;
20
+ };
21
+ export type WizardPrompter = {
22
+ intro: (title: string) => Promise<void>;
23
+ outro: (message: string) => Promise<void>;
24
+ note: (message: string, title?: string) => Promise<void>;
25
+ select: <T>(params: WizardSelectParams<T>) => Promise<T>;
26
+ text: (params: WizardTextParams) => Promise<string>;
27
+ confirm: (params: WizardConfirmParams) => Promise<boolean>;
28
+ };
29
+ export declare class WizardCancelledError extends Error {
30
+ constructor();
31
+ }
@@ -0,0 +1,6 @@
1
+ export class WizardCancelledError extends Error {
2
+ constructor() {
3
+ super("Wizard cancelled by user");
4
+ this.name = "WizardCancelledError";
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-claw",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Wrap OpenCode with persistent memory, messaging channels, and cron jobs",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,7 +47,7 @@
47
47
  "node": ">=20"
48
48
  },
49
49
  "scripts": {
50
- "start": "bun run src/index.ts",
50
+ "start": "bun run src/cli.ts",
51
51
  "build": "tsc --build",
52
52
  "prepublishOnly": "npm run build",
53
53
  "typecheck": "bun x tsc --noEmit",
@@ -58,12 +58,13 @@
58
58
  "test:e2e": "bun test test/"
59
59
  },
60
60
  "dependencies": {
61
- "@opencode-ai/sdk": "^1.2.10",
62
- "@opencode-ai/plugin": "^1.2.10",
63
- "grammy": "^1.35.0",
61
+ "@clack/prompts": "^1.0.1",
64
62
  "@grammyjs/runner": "^2.0.3",
63
+ "@opencode-ai/plugin": "^1.2.10",
64
+ "@opencode-ai/sdk": "^1.2.10",
65
65
  "@slack/bolt": "^4.3.0",
66
66
  "@whiskeysockets/baileys": "^6.7.16",
67
+ "grammy": "^1.35.0",
67
68
  "node-cron": "^3.0.3",
68
69
  "zod": "^3.24.0"
69
70
  },