clawless 0.1.2

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/TESTING.md ADDED
@@ -0,0 +1,239 @@
1
+ # Scheduler API Manual Testing Guide
2
+
3
+ This guide walks through manual testing of the scheduler API functionality.
4
+
5
+ ## Prerequisites
6
+
7
+ 1. The bridge must be running with a valid Telegram bot token
8
+ 2. You must have sent at least one message to the bot to establish a chat binding
9
+ 3. `curl` and `jq` must be installed for the test commands
10
+
11
+ ## Test Setup
12
+
13
+ 1. Start the bridge in development mode:
14
+ ```bash
15
+ npm run dev
16
+ ```
17
+
18
+ 2. In another terminal, verify the server is running:
19
+ ```bash
20
+ curl http://127.0.0.1:8788/healthz
21
+ # Expected: {"ok":true}
22
+ ```
23
+
24
+ ## Test Cases
25
+
26
+ ### Test 1: Create a Recurring Schedule
27
+
28
+ Create a schedule that runs every minute:
29
+
30
+ ```bash
31
+ curl -X POST http://127.0.0.1:8788/api/schedule \
32
+ -H "Content-Type: application/json" \
33
+ -d '{
34
+ "message": "What is the current time?",
35
+ "description": "Test recurring - every minute",
36
+ "cronExpression": "* * * * *"
37
+ }' | jq .
38
+ ```
39
+
40
+ **Expected Result:**
41
+ - HTTP 201 response
42
+ - JSON with `ok: true` and schedule details including an `id`
43
+
44
+ **Save the schedule ID** from the response for later tests.
45
+
46
+ ### Test 2: List All Schedules
47
+
48
+ ```bash
49
+ curl http://127.0.0.1:8788/api/schedule | jq .
50
+ ```
51
+
52
+ **Expected Result:**
53
+ - HTTP 200 response
54
+ - JSON array containing the schedule from Test 1
55
+
56
+ ### Test 3: Get a Specific Schedule
57
+
58
+ Replace `SCHEDULE_ID` with the ID from Test 1:
59
+
60
+ ```bash
61
+ curl http://127.0.0.1:8788/api/schedule/SCHEDULE_ID | jq .
62
+ ```
63
+
64
+ **Expected Result:**
65
+ - HTTP 200 response
66
+ - JSON with the schedule details
67
+
68
+ ### Test 4: Wait for Schedule Execution
69
+
70
+ Wait for 60-65 seconds and observe:
71
+
72
+ 1. Check the bridge logs for:
73
+ ```
74
+ [timestamp] Executing scheduled job { scheduleId: '...', message: '...' }
75
+ ```
76
+
77
+ 2. Check your Telegram chat for a message like:
78
+ ```
79
+ 🔔 Scheduled task completed:
80
+
81
+ Test recurring - every minute
82
+
83
+ [Gemini's response about the current time]
84
+ ```
85
+
86
+ ### Test 5: Create a One-Time Schedule
87
+
88
+ Create a schedule that runs in 30 seconds:
89
+
90
+ ```bash
91
+ # Generate a timestamp 30 seconds from now
92
+ RUN_AT=$(date -u -d "+30 seconds" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -v+30S +"%Y-%m-%dT%H:%M:%SZ")
93
+
94
+ curl -X POST http://127.0.0.1:8788/api/schedule \
95
+ -H "Content-Type: application/json" \
96
+ -d "{
97
+ \"message\": \"This is a one-time test message\",
98
+ \"description\": \"Test one-time schedule\",
99
+ \"oneTime\": true,
100
+ \"runAt\": \"${RUN_AT}\"
101
+ }" | jq .
102
+ ```
103
+
104
+ **Expected Result:**
105
+ - HTTP 201 response
106
+ - Schedule created with `oneTime: true` and `runAt` timestamp
107
+
108
+ Wait 30+ seconds and verify:
109
+ 1. The message appears in Telegram
110
+ 2. The schedule is automatically removed (verify with List API)
111
+
112
+ ### Test 6: Test Invalid Cron Expression
113
+
114
+ ```bash
115
+ curl -X POST http://127.0.0.1:8788/api/schedule \
116
+ -H "Content-Type: application/json" \
117
+ -d '{
118
+ "message": "Test",
119
+ "cronExpression": "invalid cron"
120
+ }' | jq .
121
+ ```
122
+
123
+ **Expected Result:**
124
+ - HTTP 400 response
125
+ - Error message about invalid cron expression
126
+
127
+ ### Test 7: Test Missing Required Fields
128
+
129
+ ```bash
130
+ curl -X POST http://127.0.0.1:8788/api/schedule \
131
+ -H "Content-Type: application/json" \
132
+ -d '{}' | jq .
133
+ ```
134
+
135
+ **Expected Result:**
136
+ - HTTP 400 response
137
+ - Error message about missing `message` field
138
+
139
+ ### Test 8: Delete a Schedule
140
+
141
+ Replace `SCHEDULE_ID` with the ID from Test 1:
142
+
143
+ ```bash
144
+ curl -X DELETE http://127.0.0.1:8788/api/schedule/SCHEDULE_ID | jq .
145
+ ```
146
+
147
+ **Expected Result:**
148
+ - HTTP 200 response
149
+ - JSON with `ok: true` and message "Schedule removed"
150
+
151
+ Verify deletion by listing schedules - the deleted schedule should not appear.
152
+
153
+ ### Test 9: Test with Gemini CLI Integration
154
+
155
+ Send a message to your Telegram bot:
156
+
157
+ ```
158
+ Create a schedule to check the weather every day at 8am
159
+ ```
160
+
161
+ **Expected Behavior:**
162
+ 1. Gemini should parse your request
163
+ 2. Call the scheduler API to create a recurring schedule
164
+ 3. Respond with confirmation of the schedule creation
165
+
166
+ Then ask:
167
+ ```
168
+ What schedules do I have?
169
+ ```
170
+
171
+ **Expected Behavior:**
172
+ 1. Gemini should query the scheduler API
173
+ 2. List all active schedules
174
+ 3. Show details in a human-readable format
175
+
176
+ Finally:
177
+ ```
178
+ Cancel the weather check schedule
179
+ ```
180
+
181
+ **Expected Behavior:**
182
+ 1. Gemini should identify the schedule to delete
183
+ 2. Call the DELETE endpoint
184
+ 3. Confirm the schedule was removed
185
+
186
+ ## Test with Authentication (Optional)
187
+
188
+ If `CALLBACK_AUTH_TOKEN` is set in your configuration:
189
+
190
+ ```bash
191
+ # This should fail with 401 Unauthorized
192
+ curl -X POST http://127.0.0.1:8788/api/schedule \
193
+ -H "Content-Type: application/json" \
194
+ -d '{
195
+ "message": "Test",
196
+ "cronExpression": "0 9 * * *"
197
+ }' | jq .
198
+
199
+ # This should succeed
200
+ curl -X POST http://127.0.0.1:8788/api/schedule \
201
+ -H "Content-Type: application/json" \
202
+ -H "x-callback-token: YOUR_TOKEN_HERE" \
203
+ -d '{
204
+ "message": "Test",
205
+ "cronExpression": "0 9 * * *"
206
+ }' | jq .
207
+ ```
208
+
209
+ ## Cleanup
210
+
211
+ After testing, remove all test schedules:
212
+
213
+ ```bash
214
+ # List all schedules
215
+ curl http://127.0.0.1:8788/api/schedule | jq -r '.schedules[].id' | while read id; do
216
+ echo "Deleting schedule: $id"
217
+ curl -X DELETE http://127.0.0.1:8788/api/schedule/$id
218
+ done
219
+ ```
220
+
221
+ ## Common Issues
222
+
223
+ ### Schedule not executing
224
+ - Verify the bridge is still running
225
+ - Check bridge logs for errors
226
+ - Ensure Telegram chat binding is established (send a message to bot first)
227
+
228
+ ### 404 Not Found
229
+ - Verify the callback server is running on port 8788
230
+ - Check if another process is using the port
231
+
232
+ ### No response in Telegram
233
+ - Ensure you've sent at least one message to the bot
234
+ - Check if `lastIncomingChatId` is logged in the bridge output
235
+ - Verify Gemini CLI is properly installed and configured
236
+
237
+ ### Timezone issues
238
+ - Set the `TZ` environment variable to your timezone
239
+ - Example: `TZ=America/New_York npm run dev`
@@ -0,0 +1,23 @@
1
+ export function buildPermissionResponse(options: any[], permissionStrategy: string) {
2
+ if (!Array.isArray(options) || options.length === 0) {
3
+ return { outcome: { outcome: 'cancelled' } };
4
+ }
5
+
6
+ if (permissionStrategy === 'cancelled') {
7
+ return { outcome: { outcome: 'cancelled' } };
8
+ }
9
+
10
+ const preferred = options.find((option: any) => option.kind === permissionStrategy);
11
+ const selectedOption = preferred || options[0];
12
+
13
+ return {
14
+ outcome: {
15
+ outcome: 'selected',
16
+ optionId: selectedOption.optionId,
17
+ },
18
+ };
19
+ }
20
+
21
+ export async function noOpAcpFileOperation(_params: any) {
22
+ return {};
23
+ }
@@ -0,0 +1,320 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { Readable, Writable } from 'node:stream';
3
+ import * as acp from '@agentclientprotocol/sdk';
4
+ import { buildPermissionResponse, noOpAcpFileOperation } from './clientHelpers.js';
5
+ import { getErrorMessage } from '../utils/error.js';
6
+
7
+ const ACP_DEBUG_STREAM = String(process.env.ACP_DEBUG_STREAM || '').toLowerCase() === 'true';
8
+ const GEMINI_KILL_GRACE_MS = parseInt(process.env.GEMINI_KILL_GRACE_MS || '5000', 10);
9
+
10
+ export interface TempAcpRunnerOptions {
11
+ scheduleId: string;
12
+ promptForGemini: string;
13
+ command: string;
14
+ args: string[];
15
+ cwd: string;
16
+ timeoutMs: number;
17
+ noOutputTimeoutMs: number;
18
+ permissionStrategy: string;
19
+ stderrTailMaxChars?: number;
20
+ logInfo: (message: string, details?: unknown) => void;
21
+ }
22
+
23
+ export async function runPromptWithTempAcp(options: TempAcpRunnerOptions): Promise<string> {
24
+ const {
25
+ scheduleId,
26
+ promptForGemini,
27
+ command,
28
+ args,
29
+ cwd,
30
+ timeoutMs,
31
+ noOutputTimeoutMs,
32
+ permissionStrategy,
33
+ stderrTailMaxChars = 4000,
34
+ logInfo,
35
+ } = options;
36
+
37
+ const tempProcess = spawn(command, args, {
38
+ stdio: ['pipe', 'pipe', 'pipe'],
39
+ cwd,
40
+ });
41
+
42
+ logInfo('Scheduler temp Gemini ACP process started', {
43
+ scheduleId,
44
+ pid: tempProcess.pid,
45
+ command,
46
+ args,
47
+ });
48
+
49
+ let tempConnection: any = null;
50
+ let tempSessionId: string | null = null;
51
+ let tempCollector: { onActivity: () => void; append: (chunk: string) => void } | null = null;
52
+ let tempStderrTail = '';
53
+ let noOutputTimeout: NodeJS.Timeout | null = null;
54
+ let overallTimeout: NodeJS.Timeout | null = null;
55
+ let cleanedUp = false;
56
+
57
+ const terminateProcessGracefully = () => {
58
+ return new Promise<void>((resolve) => {
59
+ if (!tempProcess || tempProcess.killed || tempProcess.exitCode !== null) {
60
+ resolve();
61
+ return;
62
+ }
63
+
64
+ let settled = false;
65
+
66
+ const finalize = (reason: string) => {
67
+ if (settled) {
68
+ return;
69
+ }
70
+ settled = true;
71
+ logInfo('Scheduler temp Gemini process termination finalized', {
72
+ scheduleId,
73
+ pid: tempProcess.pid,
74
+ reason,
75
+ });
76
+ resolve();
77
+ };
78
+
79
+ tempProcess.once('exit', () => finalize('exit'));
80
+
81
+ logInfo('Scheduler temp Gemini process SIGTERM', {
82
+ scheduleId,
83
+ pid: tempProcess.pid,
84
+ graceMs: GEMINI_KILL_GRACE_MS,
85
+ });
86
+ tempProcess.kill('SIGTERM');
87
+
88
+ setTimeout(() => {
89
+ if (settled || tempProcess.killed || tempProcess.exitCode !== null) {
90
+ finalize('already-exited');
91
+ return;
92
+ }
93
+
94
+ logInfo('Scheduler temp Gemini process SIGKILL escalation', {
95
+ scheduleId,
96
+ pid: tempProcess.pid,
97
+ });
98
+ tempProcess.kill('SIGKILL');
99
+ finalize('sigkill');
100
+ }, Math.max(0, GEMINI_KILL_GRACE_MS));
101
+ });
102
+ };
103
+
104
+ const appendTempStderrTail = (text: string) => {
105
+ tempStderrTail = `${tempStderrTail}${text}`;
106
+ if (tempStderrTail.length > stderrTailMaxChars) {
107
+ tempStderrTail = tempStderrTail.slice(-stderrTailMaxChars);
108
+ }
109
+ };
110
+
111
+ const clearTimers = () => {
112
+ if (noOutputTimeout) {
113
+ clearTimeout(noOutputTimeout);
114
+ noOutputTimeout = null;
115
+ }
116
+ if (overallTimeout) {
117
+ clearTimeout(overallTimeout);
118
+ overallTimeout = null;
119
+ }
120
+ };
121
+
122
+ const cleanup = async () => {
123
+ if (cleanedUp) {
124
+ return;
125
+ }
126
+ cleanedUp = true;
127
+ clearTimers();
128
+
129
+ try {
130
+ if (tempConnection && tempSessionId) {
131
+ await tempConnection.cancel({ sessionId: tempSessionId });
132
+ }
133
+ } catch (_) {
134
+ }
135
+
136
+ if (!tempProcess.killed && tempProcess.exitCode === null) {
137
+ await terminateProcessGracefully();
138
+ }
139
+
140
+ logInfo('Scheduler temp Gemini ACP process cleanup complete', {
141
+ scheduleId,
142
+ pid: tempProcess.pid,
143
+ });
144
+ };
145
+
146
+ tempProcess.stderr.on('data', (chunk: Buffer) => {
147
+ const rawText = chunk.toString();
148
+ appendTempStderrTail(rawText);
149
+ const text = rawText.trim();
150
+ if (text) {
151
+ console.error(`[gemini:scheduler:${scheduleId}] ${text}`);
152
+ }
153
+ tempCollector?.onActivity();
154
+ });
155
+
156
+ tempProcess.on('error', (error: Error) => {
157
+ logInfo('Scheduler temp Gemini ACP process error', {
158
+ scheduleId,
159
+ pid: tempProcess.pid,
160
+ error: error.message,
161
+ });
162
+ });
163
+
164
+ tempProcess.on('close', (code: number | null, signal: NodeJS.Signals | null) => {
165
+ logInfo('Scheduler temp Gemini ACP process exited', {
166
+ scheduleId,
167
+ pid: tempProcess.pid,
168
+ code,
169
+ signal,
170
+ });
171
+ });
172
+
173
+ try {
174
+ const input = Writable.toWeb(tempProcess.stdin) as unknown as WritableStream<Uint8Array>;
175
+ const output = Readable.toWeb(tempProcess.stdout) as unknown as ReadableStream<Uint8Array>;
176
+ const stream = acp.ndJsonStream(input, output);
177
+
178
+ const tempClient = {
179
+ async requestPermission(params: any) {
180
+ return buildPermissionResponse(params?.options, permissionStrategy);
181
+ },
182
+ async sessionUpdate(params: any) {
183
+ if (!tempCollector || params.sessionId !== tempSessionId) {
184
+ return;
185
+ }
186
+
187
+ tempCollector.onActivity();
188
+
189
+ if (params.update?.sessionUpdate === 'agent_message_chunk' && params.update?.content?.type === 'text') {
190
+ const chunkText = params.update.content.text;
191
+ tempCollector.append(chunkText);
192
+ }
193
+ },
194
+ async readTextFile(_params: any) {
195
+ return noOpAcpFileOperation(_params);
196
+ },
197
+ async writeTextFile(_params: any) {
198
+ return noOpAcpFileOperation(_params);
199
+ },
200
+ };
201
+
202
+ tempConnection = new acp.ClientSideConnection(() => tempClient, stream);
203
+
204
+ await tempConnection.initialize({
205
+ protocolVersion: acp.PROTOCOL_VERSION,
206
+ clientCapabilities: {},
207
+ });
208
+
209
+ const session = await tempConnection.newSession({
210
+ cwd,
211
+ mcpServers: [],
212
+ });
213
+ tempSessionId = session.sessionId;
214
+
215
+ return await new Promise<string>((resolve, reject) => {
216
+ let response = '';
217
+ let settled = false;
218
+ const startedAt = Date.now();
219
+ let chunkCount = 0;
220
+ let firstChunkAt: number | null = null;
221
+
222
+ const settle = async (handler: () => void) => {
223
+ if (settled) {
224
+ return;
225
+ }
226
+ settled = true;
227
+ await cleanup();
228
+ handler();
229
+ };
230
+
231
+ const refreshNoOutputTimer = () => {
232
+ if (!noOutputTimeoutMs || noOutputTimeoutMs <= 0) {
233
+ return;
234
+ }
235
+ if (noOutputTimeout) {
236
+ clearTimeout(noOutputTimeout);
237
+ }
238
+ noOutputTimeout = setTimeout(async () => {
239
+ try {
240
+ if (tempConnection && tempSessionId) {
241
+ await tempConnection.cancel({ sessionId: tempSessionId });
242
+ }
243
+ } catch (_) {
244
+ }
245
+
246
+ await settle(() => reject(new Error(`Scheduler Gemini ACP produced no output for ${noOutputTimeoutMs}ms`)));
247
+ }, noOutputTimeoutMs);
248
+ };
249
+
250
+ overallTimeout = setTimeout(async () => {
251
+ try {
252
+ if (tempConnection && tempSessionId) {
253
+ await tempConnection.cancel({ sessionId: tempSessionId });
254
+ }
255
+ } catch (_) {
256
+ }
257
+
258
+ await settle(() => reject(new Error(`Scheduler Gemini ACP timed out after ${timeoutMs}ms`)));
259
+ }, timeoutMs);
260
+
261
+ tempCollector = {
262
+ onActivity: refreshNoOutputTimer,
263
+ append: (chunk: string) => {
264
+ refreshNoOutputTimer();
265
+ chunkCount += 1;
266
+ if (!firstChunkAt) {
267
+ firstChunkAt = Date.now();
268
+ }
269
+ if (ACP_DEBUG_STREAM) {
270
+ logInfo('Scheduler ACP chunk received', {
271
+ scheduleId,
272
+ chunkIndex: chunkCount,
273
+ chunkLength: chunk.length,
274
+ elapsedMs: Date.now() - startedAt,
275
+ bufferLengthBeforeAppend: response.length,
276
+ });
277
+ }
278
+ response += chunk;
279
+ },
280
+ };
281
+
282
+ refreshNoOutputTimer();
283
+
284
+ tempConnection.prompt({
285
+ sessionId: tempSessionId,
286
+ prompt: [{ type: 'text', text: promptForGemini }],
287
+ })
288
+ .then(async (result: any) => {
289
+ if (ACP_DEBUG_STREAM) {
290
+ logInfo('Scheduler ACP prompt stop reason', {
291
+ scheduleId,
292
+ stopReason: result?.stopReason || '(none)',
293
+ chunkCount,
294
+ firstChunkDelayMs: firstChunkAt ? firstChunkAt - startedAt : null,
295
+ elapsedMs: Date.now() - startedAt,
296
+ bufferedLength: response.length,
297
+ });
298
+ }
299
+ if (result?.stopReason === 'cancelled' && !response) {
300
+ await settle(() => reject(new Error('Scheduler Gemini ACP prompt was cancelled')));
301
+ return;
302
+ }
303
+
304
+ await settle(() => resolve(response || 'No response received.'));
305
+ })
306
+ .catch(async (error: any) => {
307
+ await settle(() => reject(new Error(error?.message || 'Scheduler Gemini ACP prompt failed')));
308
+ });
309
+ });
310
+ } catch (error: any) {
311
+ logInfo('Scheduler temporary Gemini ACP run failed', {
312
+ scheduleId,
313
+ error: getErrorMessage(error),
314
+ stderrTail: tempStderrTail || '(empty)',
315
+ });
316
+ throw error;
317
+ } finally {
318
+ await cleanup();
319
+ }
320
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "telegramToken": "your_telegram_bot_token_here",
3
+ "typingIntervalMs": 4000,
4
+ "geminiCommand": "gemini",
5
+ "geminiApprovalMode": "yolo",
6
+ "acpPermissionStrategy": "allow_once",
7
+ "geminiTimeoutMs": 900000,
8
+ "geminiNoOutputTimeoutMs": 60000,
9
+ "geminiKillGraceMs": 5000,
10
+ "acpStreamStdout": false,
11
+ "acpDebugStream": false,
12
+ "maxResponseLength": 4000,
13
+ "heartbeatIntervalMs": 60000,
14
+ "callbackHost": "localhost",
15
+ "callbackPort": 8788,
16
+ "callbackAuthToken": "",
17
+ "callbackMaxBodyBytes": 65536,
18
+ "schedulesFilePath": "~/.clawless/schedules.json"
19
+ }