@oyasmi/pipiclaw 0.6.1 → 0.6.3

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/dist/log.js CHANGED
@@ -1,4 +1,7 @@
1
- import chalk from "chalk";
1
+ import { styleText } from "node:util";
2
+ function color(style, text) {
3
+ return styleText(style, text);
4
+ }
2
5
  function timestamp() {
3
6
  const now = new Date();
4
7
  const hh = String(now.getHours()).padStart(2, "0");
@@ -50,91 +53,91 @@ function formatToolArgs(args) {
50
53
  }
51
54
  // User messages
52
55
  export function logUserMessage(ctx, text) {
53
- console.log(chalk.green(`${timestamp()} ${formatContext(ctx)} ${text}`));
56
+ console.log(color("green", `${timestamp()} ${formatContext(ctx)} ${text}`));
54
57
  }
55
58
  // Tool execution
56
59
  export function logToolStart(ctx, toolName, label, args) {
57
60
  const formattedArgs = formatToolArgs(args);
58
- console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ↳ ${toolName}: ${label}`));
61
+ console.log(color("yellow", `${timestamp()} ${formatContext(ctx)} ↳ ${toolName}: ${label}`));
59
62
  if (formattedArgs) {
60
63
  const indented = formattedArgs
61
64
  .split("\n")
62
65
  .map((line) => ` ${line}`)
63
66
  .join("\n");
64
- console.log(chalk.dim(indented));
67
+ console.log(color("dim", indented));
65
68
  }
66
69
  }
67
70
  export function logToolSuccess(ctx, toolName, durationMs, result) {
68
71
  const duration = (durationMs / 1000).toFixed(1);
69
- console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ ${toolName} (${duration}s)`));
72
+ console.log(color("yellow", `${timestamp()} ${formatContext(ctx)} ✓ ${toolName} (${duration}s)`));
70
73
  const truncated = truncate(result, 1000);
71
74
  if (truncated) {
72
75
  const indented = truncated
73
76
  .split("\n")
74
77
  .map((line) => ` ${line}`)
75
78
  .join("\n");
76
- console.log(chalk.dim(indented));
79
+ console.log(color("dim", indented));
77
80
  }
78
81
  }
79
82
  export function logToolError(ctx, toolName, durationMs, error) {
80
83
  const duration = (durationMs / 1000).toFixed(1);
81
- console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✗ ${toolName} (${duration}s)`));
84
+ console.log(color("yellow", `${timestamp()} ${formatContext(ctx)} ✗ ${toolName} (${duration}s)`));
82
85
  const truncated = truncate(error, 1000);
83
86
  const indented = truncated
84
87
  .split("\n")
85
88
  .map((line) => ` ${line}`)
86
89
  .join("\n");
87
- console.log(chalk.dim(indented));
90
+ console.log(color("dim", indented));
88
91
  }
89
92
  // Response streaming
90
93
  export function logResponseStart(ctx) {
91
- console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} → Streaming response...`));
94
+ console.log(color("yellow", `${timestamp()} ${formatContext(ctx)} → Streaming response...`));
92
95
  }
93
96
  export function logThinking(ctx, thinking) {
94
- console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💭 Thinking`));
97
+ console.log(color("yellow", `${timestamp()} ${formatContext(ctx)} 💭 Thinking`));
95
98
  const truncated = truncate(thinking, 1000);
96
99
  const indented = truncated
97
100
  .split("\n")
98
101
  .map((line) => ` ${line}`)
99
102
  .join("\n");
100
- console.log(chalk.dim(indented));
103
+ console.log(color("dim", indented));
101
104
  }
102
105
  export function logResponse(ctx, text) {
103
- console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💬 Response`));
106
+ console.log(color("yellow", `${timestamp()} ${formatContext(ctx)} 💬 Response`));
104
107
  const truncated = truncate(text, 1000);
105
108
  const indented = truncated
106
109
  .split("\n")
107
110
  .map((line) => ` ${line}`)
108
111
  .join("\n");
109
- console.log(chalk.dim(indented));
112
+ console.log(color("dim", indented));
110
113
  }
111
114
  // Control
112
115
  export function logStopRequest(ctx) {
113
- console.log(chalk.green(`${timestamp()} ${formatContext(ctx)} stop`));
114
- console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ⊗ Stop requested - aborting`));
116
+ console.log(color("green", `${timestamp()} ${formatContext(ctx)} stop`));
117
+ console.log(color("yellow", `${timestamp()} ${formatContext(ctx)} ⊗ Stop requested - aborting`));
115
118
  }
116
119
  // System
117
120
  export function logInfo(message) {
118
- console.log(chalk.blue(`${timestamp()} [system] ${message}`));
121
+ console.log(color("blue", `${timestamp()} [system] ${message}`));
119
122
  }
120
123
  export function logWarning(message, details) {
121
- console.log(chalk.yellow(`${timestamp()} [system] ⚠ ${message}`));
124
+ console.log(color("yellow", `${timestamp()} [system] ⚠ ${message}`));
122
125
  if (details) {
123
126
  const indented = details
124
127
  .split("\n")
125
128
  .map((line) => ` ${line}`)
126
129
  .join("\n");
127
- console.log(chalk.dim(indented));
130
+ console.log(color("dim", indented));
128
131
  }
129
132
  }
130
133
  export function logAgentError(ctx, error) {
131
134
  const context = ctx === "system" ? "[system]" : formatContext(ctx);
132
- console.log(chalk.yellow(`${timestamp()} ${context} ✗ Agent error`));
135
+ console.log(color("yellow", `${timestamp()} ${context} ✗ Agent error`));
133
136
  const indented = error
134
137
  .split("\n")
135
138
  .map((line) => ` ${line}`)
136
139
  .join("\n");
137
- console.log(chalk.dim(indented));
140
+ console.log(color("dim", indented));
138
141
  }
139
142
  // Usage summary
140
143
  export function logUsageSummary(ctx, usage, contextTokens, contextWindow) {
@@ -164,8 +167,8 @@ export function logUsageSummary(ctx, usage, contextTokens, contextWindow) {
164
167
  lines.push(`**Total: $${usage.cost.total.toFixed(4)}**`);
165
168
  const summary = lines.join("\n");
166
169
  // Log to console
167
- console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💰 Usage`));
168
- console.log(chalk.dim(` ${usage.input.toLocaleString()} in + ${usage.output.toLocaleString()} out` +
170
+ console.log(color("yellow", `${timestamp()} ${formatContext(ctx)} 💰 Usage`));
171
+ console.log(color("dim", ` ${usage.input.toLocaleString()} in + ${usage.output.toLocaleString()} out` +
169
172
  (usage.cacheRead > 0 || usage.cacheWrite > 0
170
173
  ? ` (${usage.cacheRead.toLocaleString()} cache read, ${usage.cacheWrite.toLocaleString()} cache write)`
171
174
  : "") +
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "crypto";
1
2
  import { existsSync, mkdirSync, writeFileSync } from "fs";
2
3
  import { mkdir, readFile, rename, writeFile } from "fs/promises";
3
4
  import { dirname, join } from "path";
@@ -62,7 +63,7 @@ function normalizeContent(content) {
62
63
  }
63
64
  async function writeAtomically(path, content) {
64
65
  await mkdir(dirname(path), { recursive: true });
65
- const tempPath = `${path}.tmp`;
66
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
66
67
  await writeFile(tempPath, content, "utf-8");
67
68
  await rename(tempPath, path);
68
69
  }
@@ -14,7 +14,7 @@ export interface MemoryLifecycleOptions {
14
14
  }
15
15
  export declare class MemoryLifecycle {
16
16
  private options;
17
- private backgroundQueue;
17
+ private durableMemoryQueue;
18
18
  private sessionRefreshQueue;
19
19
  private turnsSinceSessionUpdate;
20
20
  private toolCallsSinceSessionUpdate;
@@ -39,12 +39,14 @@ export declare class MemoryLifecycle {
39
39
  private refreshSessionMemory;
40
40
  private runSessionRefreshSerial;
41
41
  private requestThresholdSessionRefresh;
42
- private enqueueBackgroundJob;
42
+ private runDurableMemoryJobSerial;
43
+ private enqueueDurableMemoryJob;
43
44
  private hasPendingAssistantSnapshot;
44
45
  private markDurableConsolidationCheckpoint;
45
46
  private logConsolidationResult;
46
47
  private scheduleIdleConsolidation;
47
48
  private runPreflightConsolidation;
49
+ private runPreflightConsolidationNow;
48
50
  private handleSessionBeforeCompact;
49
51
  private handleSessionCompact;
50
52
  private handleSessionBeforeSwitch;
@@ -5,7 +5,7 @@ const IDLE_CONSOLIDATION_DELAY_MS = 60_000;
5
5
  export class MemoryLifecycle {
6
6
  constructor(options) {
7
7
  this.options = options;
8
- this.backgroundQueue = Promise.resolve();
8
+ this.durableMemoryQueue = Promise.resolve();
9
9
  this.sessionRefreshQueue = Promise.resolve();
10
10
  this.turnsSinceSessionUpdate = 0;
11
11
  this.toolCallsSinceSessionUpdate = 0;
@@ -75,15 +75,16 @@ export class MemoryLifecycle {
75
75
  }
76
76
  async flushForShutdown() {
77
77
  this.clearIdleConsolidationTimer();
78
- const run = async () => {
78
+ await this.runDurableMemoryJobSerial(async () => {
79
79
  if (!this.hasPendingAssistantSnapshot()) {
80
80
  return;
81
81
  }
82
- await this.runPreflightConsolidation("shutdown");
83
- };
84
- const resultPromise = this.backgroundQueue.then(run, run);
85
- this.backgroundQueue = resultPromise.then(() => undefined, () => undefined);
86
- await resultPromise;
82
+ const messageSnapshot = [...this.options.getMessages()];
83
+ const sessionEntrySnapshot = [...this.options.getSessionEntries()];
84
+ const revisionSnapshot = this.durableRevision;
85
+ const settings = this.options.getSessionMemorySettings();
86
+ await this.runPreflightConsolidationNow("shutdown", messageSnapshot, sessionEntrySnapshot, revisionSnapshot, settings);
87
+ });
87
88
  }
88
89
  clearIdleConsolidationTimer() {
89
90
  if (!this.idleConsolidationTimer) {
@@ -152,8 +153,13 @@ export class MemoryLifecycle {
152
153
  this.thresholdRefreshQueued = false;
153
154
  });
154
155
  }
155
- enqueueBackgroundJob(job, failureMessage) {
156
- this.backgroundQueue = this.backgroundQueue.then(job).catch((error) => {
156
+ runDurableMemoryJobSerial(job) {
157
+ const resultPromise = this.durableMemoryQueue.then(job, job);
158
+ this.durableMemoryQueue = resultPromise.then(() => undefined, () => undefined);
159
+ return resultPromise;
160
+ }
161
+ enqueueDurableMemoryJob(job, failureMessage) {
162
+ void this.runDurableMemoryJobSerial(job).catch((error) => {
157
163
  const message = error instanceof Error ? error.message : String(error);
158
164
  log.logWarning(failureMessage, message);
159
165
  });
@@ -186,7 +192,7 @@ export class MemoryLifecycle {
186
192
  const messageSnapshot = [...this.options.getMessages()];
187
193
  const sessionEntrySnapshot = [...this.options.getSessionEntries()];
188
194
  const revisionSnapshot = this.durableRevision;
189
- this.enqueueBackgroundJob(async () => {
195
+ this.enqueueDurableMemoryJob(async () => {
190
196
  try {
191
197
  log.logInfo(`[${this.options.channelId}] Memory consolidation starting (idle)`);
192
198
  const result = await runInlineConsolidation(this.buildRunOptions(messageSnapshot, sessionEntrySnapshot));
@@ -210,6 +216,11 @@ export class MemoryLifecycle {
210
216
  const sessionEntrySnapshot = sessionEntries ? [...sessionEntries] : [...this.options.getSessionEntries()];
211
217
  const revisionSnapshot = this.durableRevision;
212
218
  const settings = this.options.getSessionMemorySettings();
219
+ await this.runDurableMemoryJobSerial(async () => {
220
+ await this.runPreflightConsolidationNow(reason, messageSnapshot, sessionEntrySnapshot, revisionSnapshot, settings);
221
+ });
222
+ }
223
+ async runPreflightConsolidationNow(reason, messageSnapshot, sessionEntrySnapshot, revisionSnapshot = this.durableRevision, settings = this.options.getSessionMemorySettings()) {
213
224
  if (this.shouldForceRefreshFor(reason, settings)) {
214
225
  await this.runSessionRefreshSerial({
215
226
  reason,
@@ -246,7 +257,7 @@ export class MemoryLifecycle {
246
257
  this.enqueueBackgroundMaintenance();
247
258
  }
248
259
  enqueueBackgroundMaintenance() {
249
- this.enqueueBackgroundJob(async () => {
260
+ this.enqueueDurableMemoryJob(async () => {
250
261
  const result = await runBackgroundMaintenance(this.buildRunOptions([], []));
251
262
  this.logBackgroundResult(result);
252
263
  }, `[${this.options.channelId}] Background memory maintenance failed`);
@@ -1,4 +1,4 @@
1
- import { type SandboxConfig } from "../sandbox.js";
1
+ import { type Executor, type SandboxConfig } from "../sandbox.js";
2
2
  import { DingTalkBot, type DingTalkConfig, type DingTalkHandler } from "./dingtalk.js";
3
3
  import { ChannelStore } from "./store.js";
4
4
  export interface BootstrapPaths {
@@ -55,7 +55,7 @@ interface RuntimeContextOptions {
55
55
  sandbox: SandboxConfig;
56
56
  dingtalkConfig: DingTalkConfig;
57
57
  createBot?: (handler: DingTalkHandler, config: DingTalkConfig) => DingTalkBot;
58
- createEventsWatcher?: (workspaceDir: string, bot: DingTalkBot) => {
58
+ createEventsWatcher?: (workspaceDir: string, bot: DingTalkBot, executor: Executor) => {
59
59
  start(): void;
60
60
  stop(): void;
61
61
  };
@@ -6,7 +6,7 @@ import { resetRunner } from "../agent/runner-factory.js";
6
6
  import * as log from "../log.js";
7
7
  import { ensureChannelMemoryFilesSync } from "../memory/files.js";
8
8
  import { APP_HOME_DIR, APP_NAME, AUTH_CONFIG_PATH, CHANNEL_CONFIG_PATH, MODELS_CONFIG_PATH, SECURITY_CONFIG_PATH, SETTINGS_CONFIG_PATH, TOOLS_CONFIG_PATH, WORKSPACE_DIR, } from "../paths.js";
9
- import { parseSandboxArg, validateSandbox } from "../sandbox.js";
9
+ import { createExecutor, parseSandboxArg, validateSandbox } from "../sandbox.js";
10
10
  import { loadSecurityConfigWithDiagnostics } from "../security/config.js";
11
11
  import { PipiclawSettingsManager } from "../settings.js";
12
12
  import { formatConfigDiagnostic } from "../shared/config-diagnostics.js";
@@ -160,6 +160,15 @@ export class BootstrapExitError extends Error {
160
160
  export function isBootstrapExitError(error) {
161
161
  return error instanceof BootstrapExitError;
162
162
  }
163
+ function readCliVersion() {
164
+ try {
165
+ const raw = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8"));
166
+ return typeof raw.version === "string" && raw.version.trim() ? raw.version : "0.0.0";
167
+ }
168
+ catch {
169
+ return "0.0.0";
170
+ }
171
+ }
163
172
  function writeTextFileIfMissing(path, content, label, created) {
164
173
  if (existsSync(path)) {
165
174
  return false;
@@ -283,11 +292,16 @@ export function parseArgs(argv, paths = DEFAULT_BOOTSTRAP_PATHS, io = console) {
283
292
  io.log("Options:");
284
293
  io.log(" --sandbox=host Run tools on host (default)");
285
294
  io.log(" --sandbox=docker:<name> Run tools in Docker container");
295
+ io.log(" --version Print the current version and exit");
286
296
  io.log("");
287
297
  io.log(`Config: ${paths.channelConfigPath}`);
288
298
  io.log(`Workspace: ${paths.workspaceDir}`);
289
299
  throw new BootstrapExitError(0);
290
300
  }
301
+ else if (arg === "--version") {
302
+ io.log(readCliVersion());
303
+ throw new BootstrapExitError(0);
304
+ }
291
305
  }
292
306
  return { sandbox };
293
307
  }
@@ -450,9 +464,10 @@ export function createRuntimeContext(options) {
450
464
  const bot = options.createBot
451
465
  ? options.createBot(handler, options.dingtalkConfig)
452
466
  : new DingTalkBot(handler, options.dingtalkConfig);
467
+ const executor = createExecutor(options.sandbox);
453
468
  const eventsWatcher = options.createEventsWatcher
454
- ? options.createEventsWatcher(options.paths.workspaceDir, bot)
455
- : createEventsWatcher(options.paths.workspaceDir, bot, loadSecurityConfigWithDiagnostics(options.paths.appHomeDir).config.commandGuard);
469
+ ? options.createEventsWatcher(options.paths.workspaceDir, bot, executor)
470
+ : createEventsWatcher(options.paths.workspaceDir, bot, executor, loadSecurityConfigWithDiagnostics(options.paths.appHomeDir).config.commandGuard);
456
471
  const shutdownWithReason = async (reason = "manual") => {
457
472
  if (shutdownPromise) {
458
473
  return shutdownPromise;
@@ -78,7 +78,13 @@ export declare class DingTalkBot {
78
78
  private clearKeepAliveTimer;
79
79
  private clearReconnectTimer;
80
80
  private clearAllTimers;
81
+ private sleep;
81
82
  private waitForDelay;
83
+ private waitForSocketState;
84
+ private markClientDisconnected;
85
+ private clearClientSocketReference;
86
+ private cleanupSocket;
87
+ private connectWithTimeout;
82
88
  private scheduleReconnect;
83
89
  start(): Promise<void>;
84
90
  private handleRawMessage;
@@ -54,6 +54,13 @@ class ChannelQueue {
54
54
  // ============================================================================
55
55
  const DINGTALK_API = "https://api.dingtalk.com";
56
56
  const TOKEN_REFRESH_SECS = 90 * 60; // 1.5 hours (tokens expire after 2 hours)
57
+ const CONNECT_ATTEMPT_TIMEOUT_MS = 10_000;
58
+ const SOCKET_CLOSE_GRACE_MS = 1_000;
59
+ const SOCKET_TERMINATE_GRACE_MS = 250;
60
+ const SOCKET_STATE_CONNECTING = 0;
61
+ const SOCKET_STATE_OPEN = 1;
62
+ const SOCKET_STATE_CLOSING = 2;
63
+ const SOCKET_STATE_CLOSED = 3;
57
64
  // ============================================================================
58
65
  // DingTalkBot
59
66
  // ============================================================================
@@ -140,6 +147,12 @@ export class DingTalkBot {
140
147
  this.clearKeepAliveTimer();
141
148
  this.clearReconnectTimer();
142
149
  }
150
+ async sleep(delayMs) {
151
+ await new Promise((resolve) => {
152
+ const timer = setTimeout(resolve, delayMs);
153
+ timer.unref?.();
154
+ });
155
+ }
143
156
  async waitForDelay(delayMs) {
144
157
  await new Promise((resolve) => {
145
158
  this.reconnectTimer = this.setTrackedTimeout(() => {
@@ -148,6 +161,81 @@ export class DingTalkBot {
148
161
  }, delayMs);
149
162
  });
150
163
  }
164
+ async waitForSocketState(socket, expectedState, timeoutMs) {
165
+ const deadline = Date.now() + timeoutMs;
166
+ while ((socket.readyState ?? SOCKET_STATE_CLOSED) !== expectedState && Date.now() < deadline) {
167
+ await this.sleep(25);
168
+ }
169
+ return (socket.readyState ?? SOCKET_STATE_CLOSED) === expectedState;
170
+ }
171
+ markClientDisconnected() {
172
+ if (!this.client) {
173
+ return;
174
+ }
175
+ Reflect.set(this.client, "connected", false);
176
+ Reflect.set(this.client, "registered", false);
177
+ Reflect.set(this.client, "reconnecting", false);
178
+ }
179
+ clearClientSocketReference() {
180
+ if (!this.client) {
181
+ return;
182
+ }
183
+ Reflect.set(this.client, "socket", undefined);
184
+ }
185
+ async cleanupSocket(reason) {
186
+ const socket = this.getSocket();
187
+ this.markClientDisconnected();
188
+ if (!socket) {
189
+ this.clearClientSocketReference();
190
+ return;
191
+ }
192
+ socket.removeAllListeners?.();
193
+ if ((socket.readyState ?? SOCKET_STATE_CLOSED) !== SOCKET_STATE_CLOSED) {
194
+ try {
195
+ socket.close?.();
196
+ }
197
+ catch (err) {
198
+ log.logWarning(`DingTalk: socket close failed during ${reason}`, err instanceof Error ? err.message : String(err));
199
+ }
200
+ const closed = await this.waitForSocketState(socket, SOCKET_STATE_CLOSED, SOCKET_CLOSE_GRACE_MS);
201
+ if (!closed) {
202
+ log.logWarning(`DingTalk: forcing socket termination during ${reason}`);
203
+ try {
204
+ socket.terminate?.();
205
+ }
206
+ catch (err) {
207
+ log.logWarning(`DingTalk: socket terminate failed during ${reason}`, err instanceof Error ? err.message : String(err));
208
+ }
209
+ await this.waitForSocketState(socket, SOCKET_STATE_CLOSED, SOCKET_TERMINATE_GRACE_MS);
210
+ }
211
+ }
212
+ this.clearClientSocketReference();
213
+ }
214
+ async connectWithTimeout() {
215
+ if (!this.client) {
216
+ throw new Error("DingTalk client is not initialized");
217
+ }
218
+ const connectPromise = Promise.resolve(this.client.connect());
219
+ let timeoutHandle = null;
220
+ const timeoutPromise = new Promise((_, reject) => {
221
+ timeoutHandle = setTimeout(() => {
222
+ reject(new Error(`connect timed out after ${CONNECT_ATTEMPT_TIMEOUT_MS}ms`));
223
+ }, CONNECT_ATTEMPT_TIMEOUT_MS);
224
+ timeoutHandle.unref?.();
225
+ });
226
+ try {
227
+ await Promise.race([connectPromise, timeoutPromise]);
228
+ }
229
+ finally {
230
+ if (timeoutHandle) {
231
+ clearTimeout(timeoutHandle);
232
+ }
233
+ }
234
+ const socket = this.getSocket();
235
+ if (!socket || socket.readyState !== SOCKET_STATE_OPEN) {
236
+ throw new Error("stream socket did not reach open state");
237
+ }
238
+ }
151
239
  scheduleReconnect(delayMs, immediate) {
152
240
  if (this.isStopped) {
153
241
  return;
@@ -173,11 +261,13 @@ export class DingTalkBot {
173
261
  }
174
262
  log.logInfo(`DingTalk: initializing stream (clientId=${this.config.clientId.substring(0, 8)}…)`);
175
263
  this.clearAllTimers();
176
- this.client = new DWClient({
264
+ const clientOptions = {
177
265
  clientId: this.config.clientId,
178
266
  clientSecret: this.config.clientSecret,
267
+ autoReconnect: false,
179
268
  keepAlive: false,
180
- });
269
+ };
270
+ this.client = new DWClient(clientOptions);
181
271
  this.client.registerCallbackListener(TOPIC_ROBOT, (msg) => {
182
272
  return this.handleRawMessage(msg);
183
273
  });
@@ -220,6 +310,8 @@ export class DingTalkBot {
220
310
  this.isReconnecting = true;
221
311
  let connectionFailed = false;
222
312
  let connected = false;
313
+ this.clearReconnectTimer();
314
+ this.clearKeepAliveTimer();
223
315
  if (!immediate && this.reconnectAttempts > 0) {
224
316
  const delay = Math.min(1000 * 2 ** this.reconnectAttempts + Math.random() * 1000, 30000);
225
317
  log.logInfo(`DingTalk: waiting ${Math.round(delay / 1000)}s before reconnecting...`);
@@ -231,10 +323,14 @@ export class DingTalkBot {
231
323
  }
232
324
  try {
233
325
  const socket = this.getSocket();
234
- if (socket?.readyState === 1 || socket?.readyState === 3) {
235
- await Promise.resolve(this.client.disconnect());
236
- }
237
- await this.client.connect();
326
+ const readyState = socket?.readyState;
327
+ if (readyState === SOCKET_STATE_CONNECTING ||
328
+ readyState === SOCKET_STATE_OPEN ||
329
+ readyState === SOCKET_STATE_CLOSING ||
330
+ readyState === SOCKET_STATE_CLOSED) {
331
+ await this.cleanupSocket("reconnect");
332
+ }
333
+ await this.connectWithTimeout();
238
334
  this.lastSocketAvailableTime = Date.now();
239
335
  this.reconnectAttempts = 0; // Success, reset backoff
240
336
  log.logInfo("DingTalk: connected to stream.");
@@ -289,6 +385,7 @@ export class DingTalkBot {
289
385
  });
290
386
  }
291
387
  catch (err) {
388
+ await this.cleanupSocket("reconnect failure");
292
389
  this.reconnectAttempts++;
293
390
  connectionFailed = true;
294
391
  log.logWarning("DingTalk: connection failed", err instanceof Error ? err.message : String(err));
@@ -311,7 +408,7 @@ export class DingTalkBot {
311
408
  }
312
409
  if (this.client) {
313
410
  try {
314
- await Promise.resolve(this.client.disconnect());
411
+ await this.cleanupSocket("stop");
315
412
  }
316
413
  catch (err) {
317
414
  log.logWarning("DingTalk: failed to disconnect cleanly", err instanceof Error ? err.message : String(err));
@@ -1,3 +1,4 @@
1
+ import type { Executor } from "../sandbox.js";
1
2
  import type { SecurityConfig } from "../security/types.js";
2
3
  import type { DingTalkBot } from "./dingtalk.js";
3
4
  export interface EventAction {
@@ -30,6 +31,7 @@ export type ScheduledEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;
30
31
  export declare class EventsWatcher {
31
32
  private eventsDir;
32
33
  private bot;
34
+ private executor;
33
35
  private commandGuardConfig?;
34
36
  private timers;
35
37
  private crons;
@@ -37,7 +39,7 @@ export declare class EventsWatcher {
37
39
  private startTime;
38
40
  private watcher;
39
41
  private knownFiles;
40
- constructor(eventsDir: string, bot: DingTalkBot, commandGuardConfig?: SecurityConfig["commandGuard"] | undefined);
42
+ constructor(eventsDir: string, bot: DingTalkBot, executor: Executor, commandGuardConfig?: SecurityConfig["commandGuard"] | undefined);
41
43
  start(): void;
42
44
  stop(): void;
43
45
  private debounce;
@@ -62,4 +64,4 @@ export declare class EventsWatcher {
62
64
  /**
63
65
  * Create and start an events watcher.
64
66
  */
65
- export declare function createEventsWatcher(workspaceDir: string, bot: DingTalkBot, commandGuardConfig?: SecurityConfig["commandGuard"]): EventsWatcher;
67
+ export declare function createEventsWatcher(workspaceDir: string, bot: DingTalkBot, executor: Executor, commandGuardConfig?: SecurityConfig["commandGuard"]): EventsWatcher;
@@ -1,4 +1,3 @@
1
- import { exec } from "child_process";
2
1
  import { Cron } from "croner";
3
2
  import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, watch, writeFileSync } from "fs";
4
3
  import { readFile } from "fs/promises";
@@ -13,9 +12,10 @@ const MAX_RETRIES = 3;
13
12
  const RETRY_BASE_MS = 100;
14
13
  const MAX_TIMEOUT_MS = 2_147_483_647;
15
14
  export class EventsWatcher {
16
- constructor(eventsDir, bot, commandGuardConfig) {
15
+ constructor(eventsDir, bot, executor, commandGuardConfig) {
17
16
  this.eventsDir = eventsDir;
18
17
  this.bot = bot;
18
+ this.executor = executor;
19
19
  this.commandGuardConfig = commandGuardConfig;
20
20
  this.timers = new Map();
21
21
  this.crons = new Map();
@@ -314,24 +314,20 @@ export class EventsWatcher {
314
314
  }
315
315
  }
316
316
  }
317
- runPreAction(action, filename) {
317
+ async runPreAction(action, filename) {
318
318
  if (this.commandGuardConfig?.enabled) {
319
319
  const guardResult = guardCommand(action.command, this.commandGuardConfig);
320
320
  if (!guardResult.allowed) {
321
321
  log.logWarning(`Pre-action command blocked by guard for ${filename}: ${guardResult.reason}`);
322
- return Promise.reject(new Error(`guard: ${guardResult.reason}`));
322
+ throw new Error(`guard: ${guardResult.reason}`);
323
323
  }
324
324
  }
325
- return new Promise((resolve, reject) => {
326
- const child = exec(action.command, { timeout: action.timeout ?? 10_000 });
327
- child.on("close", (code) => {
328
- if (code === 0)
329
- resolve();
330
- else
331
- reject(new Error(`exit ${code}`));
332
- });
333
- child.on("error", reject);
334
- });
325
+ const timeoutMs = action.timeout ?? 10_000;
326
+ const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
327
+ const result = await this.executor.exec(action.command, { timeout: timeoutSeconds });
328
+ if (result.code !== 0) {
329
+ throw new Error(`exit ${result.code}`);
330
+ }
335
331
  }
336
332
  deleteFile(filename) {
337
333
  const filePath = join(this.eventsDir, filename);
@@ -376,7 +372,7 @@ export class EventsWatcher {
376
372
  /**
377
373
  * Create and start an events watcher.
378
374
  */
379
- export function createEventsWatcher(workspaceDir, bot, commandGuardConfig) {
375
+ export function createEventsWatcher(workspaceDir, bot, executor, commandGuardConfig) {
380
376
  const eventsDir = join(workspaceDir, "events");
381
- return new EventsWatcher(eventsDir, bot, commandGuardConfig);
377
+ return new EventsWatcher(eventsDir, bot, executor, commandGuardConfig);
382
378
  }
package/dist/settings.js CHANGED
@@ -194,13 +194,13 @@ export class PipiclawSettingsManager {
194
194
  }
195
195
  // Compaction details
196
196
  getCompactionReserveTokens() {
197
- return DEFAULT_COMPACTION.reserveTokens;
197
+ return this.getCompactionSettings().reserveTokens;
198
198
  }
199
199
  getCompactionKeepRecentTokens() {
200
- return DEFAULT_COMPACTION.keepRecentTokens;
200
+ return this.getCompactionSettings().keepRecentTokens;
201
201
  }
202
202
  getBranchSummarySettings() {
203
- return { reserveTokens: 16384 };
203
+ return { reserveTokens: this.getCompactionSettings().reserveTokens };
204
204
  }
205
205
  getBranchSummarySkipPrompt() {
206
206
  return false;
@@ -28,6 +28,12 @@ const readSchema = Type.Object({
28
28
  offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })),
29
29
  limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })),
30
30
  });
31
+ function countTextLines(content) {
32
+ if (content.length === 0) {
33
+ return 0;
34
+ }
35
+ return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
36
+ }
31
37
  function formatPathBlockMessage(resolvedPath, category, reason) {
32
38
  const lines = [`Path blocked${category ? ` [${category}]` : ""}`];
33
39
  if (reason) {
@@ -84,16 +90,17 @@ export function createReadTool(executor, options = {}) {
84
90
  };
85
91
  }
86
92
  // Get total line count first
87
- const countResult = await executor.exec(`wc -l < ${shellEscapePath(path)}`, { signal });
93
+ const countResult = await executor.exec(`awk 'END { print NR }' ${shellEscapePath(path)}`, { signal });
88
94
  if (countResult.code !== 0) {
89
95
  throw new Error(countResult.stderr || `Failed to read file: ${path}`);
90
96
  }
91
- const totalFileLines = Number.parseInt(countResult.stdout.trim(), 10) + 1; // wc -l counts newlines, not lines
97
+ const totalFileLines = Number.parseInt(countResult.stdout.trim(), 10);
92
98
  // Apply offset if specified (1-indexed)
93
99
  const startLine = offset ? Math.max(1, offset) : 1;
94
100
  const startLineDisplay = startLine;
95
101
  // Check if offset is out of bounds
96
- if (startLine > totalFileLines) {
102
+ if ((totalFileLines === 0 && offset !== undefined && startLine > 1) ||
103
+ (totalFileLines > 0 && startLine > totalFileLines)) {
97
104
  throw new Error(`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`);
98
105
  }
99
106
  // Read content with offset
@@ -113,7 +120,7 @@ export function createReadTool(executor, options = {}) {
113
120
  // Apply user limit if specified
114
121
  if (limit !== undefined) {
115
122
  const lines = selectedContent.split("\n");
116
- const endLine = Math.min(limit, lines.length);
123
+ const endLine = Math.min(limit, countTextLines(selectedContent));
117
124
  selectedContent = lines.slice(0, endLine).join("\n");
118
125
  userLimitedLines = endLine;
119
126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oyasmi/pipiclaw",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "An AI assistant runtime for coding and team workflows, with DingTalk AI Cards, sub-agents, memory, and scheduled events.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,8 +21,8 @@
21
21
  "LICENSE"
22
22
  ],
23
23
  "scripts": {
24
- "clean": "shx rm -rf dist coverage",
25
- "build": "tsc -p tsconfig.build.json && shx chmod +x dist/main.js",
24
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true }); require('node:fs').rmSync('coverage', { recursive: true, force: true });\"",
25
+ "build": "tsc -p tsconfig.build.json && node -e \"require('node:fs').chmodSync('dist/main.js', 0o755)\"",
26
26
  "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
27
27
  "lint": "biome check .",
28
28
  "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -39,7 +39,6 @@
39
39
  "@mozilla/readability": "^0.6.0",
40
40
  "@sinclair/typebox": "^0.34.0",
41
41
  "axios": "^1.7.0",
42
- "chalk": "^5.6.2",
43
42
  "croner": "^9.1.0",
44
43
  "diff": "^8.0.2",
45
44
  "dingtalk-stream": "^2.1.4",
@@ -51,11 +50,9 @@
51
50
  },
52
51
  "devDependencies": {
53
52
  "@biomejs/biome": "2.3.5",
54
- "@types/diff": "^7.0.2",
55
53
  "@types/jsdom": "^28.0.1",
56
54
  "@types/node": "^24.3.0",
57
55
  "@vitest/coverage-v8": "^3.2.4",
58
- "shx": "^0.4.0",
59
56
  "typescript": "^5.7.3",
60
57
  "vitest": "^3.2.4"
61
58
  },