@hienlh/ppm 0.9.61 → 0.9.63

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.63] - 2026-04-08
4
+
5
+ ### Fixed
6
+ - **Cloud auto-link**: `ppm cloud login` now auto-links device (no separate `ppm cloud link` needed). `ppm cloud logout` auto-unlinks.
7
+ - **Supervisor cloud monitor**: Supervisor periodically checks cloud-device.json and auto-connects/disconnects/reconnects WS as needed. Fixes "stuck offline" after upgrade or file loss.
8
+ - **Stale tests**: Aligned usage-cache and chat-routes tests with current API response shapes.
9
+
10
+ ## [0.9.62] - 2026-04-08
11
+
12
+ ### Fixed
13
+ - **SDK subprocess crash auto-retry**: When the Claude Code subprocess crashes (exit code 1), PPM now automatically retries once with a fresh subprocess after a 1s delay, instead of immediately showing the error. Only surfaces the crash message if the retry also fails.
14
+
3
15
  ## [0.9.61] - 2026-04-08
4
16
 
5
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.9.61",
3
+ "version": "0.9.63",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  "build": "bun run build:web && bun build src/index.ts --compile --outfile dist/ppm",
19
19
  "start": "bun run src/index.ts start",
20
20
  "typecheck": "bunx tsc --noEmit",
21
+ "generate:bot": "bun scripts/generate-bot-coordinator.ts --update",
21
22
  "prepublishOnly": "bun run build:web"
22
23
  },
23
24
  "devDependencies": {
@@ -0,0 +1,452 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Auto-generate PPMBot coordinator identity from Commander.js CLI source.
4
+ *
5
+ * Parses src/index.ts + src/cli/commands/*.ts to extract all commands,
6
+ * descriptions, options, and arguments. Produces coordinator.md content
7
+ * with full CLI reference, decision framework, and safety rules.
8
+ *
9
+ * Usage:
10
+ * bun scripts/generate-bot-coordinator.ts # print to stdout
11
+ * bun scripts/generate-bot-coordinator.ts --update # write coordinator.md + update source
12
+ * bun scripts/generate-bot-coordinator.ts --write-md # only write ~/.ppm/bot/coordinator.md
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
16
+ import { join, resolve } from "node:path";
17
+ import { homedir } from "node:os";
18
+
19
+ const ROOT = resolve(import.meta.dir, "..");
20
+ const INDEX_PATH = join(ROOT, "src", "index.ts");
21
+ const CMD_DIR = join(ROOT, "src", "cli", "commands");
22
+ const SESSION_PATH = join(ROOT, "src", "services", "ppmbot", "ppmbot-session.ts");
23
+ const COORDINATOR_MD = join(homedir(), ".ppm", "bot", "coordinator.md");
24
+
25
+ // ── Types ──────────────────────────────────────────────────────────────
26
+
27
+ interface CliOption {
28
+ flags: string;
29
+ description: string;
30
+ defaultValue?: string;
31
+ required?: boolean;
32
+ }
33
+
34
+ interface CliCommand {
35
+ /** Full display name including parent path, e.g. "branch create" */
36
+ displayName: string;
37
+ description: string;
38
+ args: string[];
39
+ options: CliOption[];
40
+ }
41
+
42
+ interface CommandGroup {
43
+ name: string;
44
+ description: string;
45
+ commands: CliCommand[];
46
+ }
47
+
48
+ // ── Parsing ────────────────────────────────────────────────────────────
49
+
50
+ /** Extract first quoted string from a regex match, handling mixed quote types */
51
+ function extractQuoted(line: string, after: string): string | null {
52
+ const idx = line.indexOf(after);
53
+ if (idx === -1) return null;
54
+ const rest = line.slice(idx + after.length);
55
+
56
+ // Match opening quote, then capture until same closing quote
57
+ const m = rest.match(/["'`]((?:[^"'`\\]|\\.)*)["'`]/);
58
+ return m ? m[1]! : null;
59
+ }
60
+
61
+ /** Extract string argument from .description("...") — handles embedded quotes */
62
+ function extractDescription(line: string): string | null {
63
+ // Try specific patterns: .description("..."), .description('...')
64
+ const dblMatch = line.match(/\.description\(\s*"([^"]*)"\s*\)/);
65
+ if (dblMatch) return dblMatch[1]!;
66
+
67
+ const sglMatch = line.match(/\.description\(\s*'([^']*)'\s*\)/);
68
+ if (sglMatch) return sglMatch[1]!;
69
+
70
+ const btMatch = line.match(/\.description\(\s*`([^`]*)`\s*\)/);
71
+ if (btMatch) return btMatch[1]!;
72
+
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * Parse a Commander.js source file into commands.
78
+ * Tracks variable assignments to resolve nested groups:
79
+ * const branch = git.command("branch") → branch.command("create") is "branch create"
80
+ *
81
+ * Handles multi-line chaining where receiver is on previous line:
82
+ * branch
83
+ * .command("create <name>")
84
+ */
85
+ function parseFile(filePath: string): CliCommand[] {
86
+ const src = readFileSync(filePath, "utf-8");
87
+ const lines = src.split("\n");
88
+ const commands: CliCommand[] = [];
89
+
90
+ // Track variable → parent path mapping
91
+ // e.g. "git" → "", "branch" → "branch", "mem" → "memory"
92
+ const varParent = new Map<string, string>();
93
+
94
+ // Track function parameters as potential receivers: function xxx(param: Command)
95
+ for (const line of lines) {
96
+ const funcParamMatch = line.match(
97
+ /function\s+\w+\(\s*(\w+)\s*:\s*Command\s*\)/,
98
+ );
99
+ if (funcParamMatch) {
100
+ varParent.set(funcParamMatch[1]!, "");
101
+ }
102
+ }
103
+
104
+ /** Last standalone variable seen (for multi-line chaining: `branch\n .command(...)`) */
105
+ let lastStandaloneVar = "";
106
+
107
+ let currentCmd: {
108
+ displayName: string;
109
+ description: string;
110
+ args: string[];
111
+ options: CliOption[];
112
+ } | null = null;
113
+
114
+ for (let i = 0; i < lines.length; i++) {
115
+ const line = lines[i]!;
116
+
117
+ // Track standalone variable references (for multi-line chaining)
118
+ const standaloneMatch = line.match(/^\s+(\w+)\s*$/);
119
+ if (standaloneMatch) {
120
+ lastStandaloneVar = standaloneMatch[1]!;
121
+ }
122
+
123
+ // Detect variable assignment: const/let varName = something.command("name")
124
+ const assignMatch = line.match(
125
+ /(?:const|let)\s+(\w+)\s*=\s*(\w+)\.command\(\s*["'`](\w+)["'`]\s*\)/,
126
+ );
127
+ if (assignMatch) {
128
+ const varName = assignMatch[1]!;
129
+ const receiver = assignMatch[2]!;
130
+ const cmdName = assignMatch[3]!;
131
+
132
+ // If this creates a known CLI group (git, bot, etc.), its path stays ""
133
+ // because formatCommand already prepends "ppm <group>"
134
+ if (cmdName in GROUP_MAP) {
135
+ varParent.set(varName, "");
136
+ } else {
137
+ const parentPath = varParent.get(receiver);
138
+ if (parentPath !== undefined) {
139
+ varParent.set(varName, parentPath ? `${parentPath} ${cmdName}` : cmdName);
140
+ } else {
141
+ varParent.set(varName, "");
142
+ }
143
+ }
144
+ continue;
145
+ }
146
+
147
+ // Detect chained .command("name") — not an assignment
148
+ const cmdMatch = line.match(/\.command\(\s*["'`]([^"'`]+)["'`]\s*\)/);
149
+ if (cmdMatch) {
150
+ // Save previous command
151
+ if (currentCmd) {
152
+ commands.push({ ...currentCmd });
153
+ }
154
+
155
+ const fullArg = cmdMatch[1]!;
156
+ const parts = fullArg.split(/\s+/);
157
+ const name = parts[0]!;
158
+ const args = parts.slice(1);
159
+
160
+ // Determine receiver: same line or previous line (multi-line chaining)
161
+ const sameLineReceiver = line.match(/(\w+)\.command\(/);
162
+ let receiver = sameLineReceiver ? sameLineReceiver[1]! : "";
163
+
164
+ // If receiver looks like a keyword (not a variable), try lastStandaloneVar
165
+ if (!receiver || receiver === "command") {
166
+ // .command() at start of line → look at previous non-empty line
167
+ receiver = lastStandaloneVar;
168
+ }
169
+
170
+ const parentPath = varParent.get(receiver) ?? "";
171
+ const displayName = parentPath ? `${parentPath} ${name}` : name;
172
+
173
+ currentCmd = { displayName, description: "", args, options: [] };
174
+ lastStandaloneVar = "";
175
+ continue;
176
+ }
177
+
178
+ if (!currentCmd) continue;
179
+
180
+ // Description
181
+ const desc = extractDescription(line);
182
+ if (desc !== null && line.includes(".description(")) {
183
+ currentCmd.description = desc;
184
+ }
185
+
186
+ // Option: .option("flags", "desc", "default?")
187
+ const optMatch = line.match(
188
+ /\.option\(\s*["'`]([^"'`]+)["'`]\s*,\s*["'`]([^"'`]+)["'`](?:\s*,\s*["'`]([^"'`]+)["'`])?\s*\)/,
189
+ );
190
+ if (optMatch) {
191
+ currentCmd.options.push({
192
+ flags: optMatch[1]!,
193
+ description: optMatch[2]!,
194
+ defaultValue: optMatch[3],
195
+ });
196
+ }
197
+
198
+ // Required option
199
+ const reqMatch = line.match(
200
+ /\.requiredOption\(\s*["'`]([^"'`]+)["'`]\s*,\s*["'`]([^"'`]+)["'`](?:\s*,\s*["'`]([^"'`]+)["'`])?\s*\)/,
201
+ );
202
+ if (reqMatch) {
203
+ currentCmd.options.push({
204
+ flags: reqMatch[1]!,
205
+ description: reqMatch[2]!,
206
+ defaultValue: reqMatch[3],
207
+ required: true,
208
+ });
209
+ }
210
+
211
+ // Argument: .argument("name")
212
+ const argMatch = line.match(/\.argument\(\s*["'`]([^"'`]+)["'`]/);
213
+ if (argMatch) {
214
+ currentCmd.args.push(argMatch[1]!);
215
+ }
216
+ }
217
+
218
+ // Push last command
219
+ if (currentCmd) {
220
+ commands.push({ ...currentCmd });
221
+ }
222
+
223
+ return commands;
224
+ }
225
+
226
+ /** Known command groups with their source files */
227
+ const GROUP_MAP: Record<string, { description: string; file: string }> = {
228
+ projects: { description: "Manage registered projects", file: "projects.ts" },
229
+ config: { description: "Configuration management", file: "config-cmd.ts" },
230
+ git: { description: "Git operations for a project", file: "git-cmd.ts" },
231
+ chat: { description: "AI chat sessions", file: "chat-cmd.ts" },
232
+ db: { description: "Database connections & queries", file: "db-cmd.ts" },
233
+ autostart: { description: "Auto-start on boot", file: "autostart.ts" },
234
+ cloud: { description: "PPM Cloud — device registry + tunnel", file: "cloud.ts" },
235
+ ext: { description: "Manage PPM extensions", file: "ext-cmd.ts" },
236
+ bot: { description: "PPMBot coordinator utilities", file: "bot-cmd.ts" },
237
+ };
238
+
239
+ function buildGroups(): CommandGroup[] {
240
+ const groups: CommandGroup[] = [];
241
+
242
+ // Top-level commands from index.ts
243
+ const indexCmds = parseFile(INDEX_PATH);
244
+ const groupNames = new Set(Object.keys(GROUP_MAP));
245
+ const topLevel = indexCmds.filter((c) => !groupNames.has(c.displayName));
246
+
247
+ if (topLevel.length > 0) {
248
+ groups.push({
249
+ name: "core",
250
+ description: "Server & system management",
251
+ commands: topLevel,
252
+ });
253
+ }
254
+
255
+ // Sub-command groups from individual files
256
+ for (const [groupName, info] of Object.entries(GROUP_MAP)) {
257
+ const filePath = join(CMD_DIR, info.file);
258
+ if (!existsSync(filePath)) continue;
259
+
260
+ const cmds = parseFile(filePath);
261
+
262
+ // Filter out the group parent command and pure sub-group declarations
263
+ // (e.g. "branch" with no description = just a group container)
264
+ const subCmds = cmds.filter((c) => {
265
+ if (c.displayName === groupName) return false;
266
+ // Skip pure group containers (no description, no args)
267
+ if (!c.description && c.args.length === 0 && c.options.length === 0) return false;
268
+ return true;
269
+ });
270
+
271
+ groups.push({
272
+ name: groupName,
273
+ description: info.description,
274
+ commands: subCmds,
275
+ });
276
+ }
277
+
278
+ return groups;
279
+ }
280
+
281
+ // ── Output Generation ──────────────────────────────────────────────────
282
+
283
+ function formatOption(opt: CliOption): string {
284
+ const req = opt.required ? " (required)" : "";
285
+ const def = opt.defaultValue ? ` [default: ${opt.defaultValue}]` : "";
286
+ return ` ${opt.flags} — ${opt.description}${req}${def}`;
287
+ }
288
+
289
+ function formatCommand(group: string, cmd: CliCommand): string {
290
+ const args = cmd.args.length > 0 ? " " + cmd.args.join(" ") : "";
291
+ const prefix = group === "core" ? "ppm" : `ppm ${group}`;
292
+ let line = `${prefix} ${cmd.displayName}${args}`;
293
+ if (cmd.description) line += `\n ${cmd.description}`;
294
+ if (cmd.options.length > 0) {
295
+ line += "\n" + cmd.options.map(formatOption).join("\n");
296
+ }
297
+ return line;
298
+ }
299
+
300
+ function generateCoordinatorMd(groups: CommandGroup[]): string {
301
+ const sections: string[] = [];
302
+
303
+ sections.push(`# PPMBot — AI Project Coordinator
304
+
305
+ You are PPMBot, a personal AI project coordinator and team leader. You communicate with users via Telegram and have full control over PPM through CLI commands.
306
+
307
+ ## Role
308
+ - Answer direct questions immediately (coding, general knowledge, quick advice)
309
+ - Delegate project-specific tasks to subagents using \`ppm bot delegate\`
310
+ - Track delegated task status and report results proactively
311
+ - Manage PPM server, projects, config, git, cloud, extensions, and databases
312
+ - Remember user preferences across conversations
313
+
314
+ ## Decision Framework
315
+ 1. Can I answer this directly without project context? → Answer now
316
+ 2. Does this reference a specific project or need file access? → Delegate with \`ppm bot delegate\`
317
+ 3. Is this about PPM management (server/config/projects/git/db/cloud/ext)? → Use CLI commands directly
318
+ 4. Is this a destructive operation? → Confirm with user first
319
+ 5. Ambiguous project? → Ask user to clarify
320
+
321
+ ## Safety Rules (CRITICAL)
322
+ Before executing destructive commands, ALWAYS confirm with the user:
323
+ - \`ppm stop\` / \`ppm down\` / \`ppm restart\` → "Are you sure you want to stop/restart PPM?"
324
+ - \`ppm db query <name> <sql>\` with writes → warn about data modification risk
325
+ - \`ppm projects remove\` → confirm project name, warn it removes from registry
326
+ - \`ppm config set\` → show current value with \`ppm config get\` BEFORE changing
327
+ - \`ppm cloud logout\` / \`ppm cloud unlink\` → confirm, warn about losing cloud sync
328
+ - \`ppm git reset\` → warn about potential data loss
329
+ - \`ppm ext remove\` → confirm extension name
330
+
331
+ ## Operational Patterns
332
+ - Before restart: check \`ppm status\` first
333
+ - Before config change: read current value with \`ppm config get <key>\`
334
+ - Before git push: check \`ppm git status --project <name>\` first
335
+ - For DB operations: always specify connection name
336
+ - For git operations: always use \`--project <name>\` flag`);
337
+
338
+ // CLI Reference
339
+ sections.push(`\n## CLI Command Reference`);
340
+
341
+ for (const group of groups) {
342
+ const heading = group.name === "core"
343
+ ? `### Core Commands (${group.description})`
344
+ : `### ppm ${group.name} — ${group.description}`;
345
+ sections.push(heading);
346
+
347
+ const cmdLines = group.commands.map((c) => formatCommand(group.name, c));
348
+ sections.push("```");
349
+ sections.push(cmdLines.join("\n\n"));
350
+ sections.push("```");
351
+ }
352
+
353
+ // Delegation section (always present, emphasized as primary tool)
354
+ sections.push(`
355
+ ## Task Delegation (Primary Tool)
356
+
357
+ ### Delegate a task to a project
358
+ \`\`\`
359
+ ppm bot delegate --chat <chatId> --project <name> --prompt "<enriched task description>"
360
+ \`\`\`
361
+ Returns task ID in JSON. Tell user you're working on it.
362
+
363
+ ### Check task status
364
+ \`\`\`
365
+ ppm bot task-status <task-id>
366
+ \`\`\`
367
+
368
+ ### Get task result
369
+ \`\`\`
370
+ ppm bot task-result <task-id>
371
+ \`\`\`
372
+
373
+ ### List recent tasks
374
+ \`\`\`
375
+ ppm bot tasks
376
+ \`\`\`
377
+
378
+ ## Memory Management
379
+ \`\`\`
380
+ ppm bot memory save "<content>" -c <category> # categories: fact|preference|decision|architecture|issue
381
+ ppm bot memory list # list saved memories
382
+ ppm bot memory forget "<topic>" # delete matching memories
383
+ \`\`\`
384
+
385
+ ## Response Style
386
+ - Keep responses concise (Telegram context — mobile-friendly)
387
+ - Use short paragraphs, no walls of text
388
+ - When delegating: acknowledge immediately, notify on completion
389
+ - Support Vietnamese and English naturally
390
+ - When showing CLI output: format for readability
391
+
392
+ ## Important
393
+ - When delegating, write an enriched prompt with full context — not just the raw user message
394
+ - Include relevant details: what the user wants, which files/features, acceptance criteria
395
+ - Each delegation creates a fresh AI session in the target project workspace
396
+ - Use \`--json\` flag when you need to parse command output programmatically`);
397
+
398
+ return sections.join("\n");
399
+ }
400
+
401
+ // ── Source Code Update ────────────────────────────────────────────────
402
+
403
+ function updateSourceDefault(content: string): void {
404
+ const src = readFileSync(SESSION_PATH, "utf-8");
405
+
406
+ const startMarker = "export const DEFAULT_COORDINATOR_IDENTITY = `";
407
+ const startIdx = src.indexOf(startMarker);
408
+ if (startIdx === -1) {
409
+ console.error("Could not find DEFAULT_COORDINATOR_IDENTITY in source");
410
+ process.exit(1);
411
+ }
412
+
413
+ const afterStart = startIdx + startMarker.length;
414
+ const endIdx = src.indexOf("\n`;\n", afterStart);
415
+ if (endIdx === -1) {
416
+ console.error("Could not find closing backtick for DEFAULT_COORDINATOR_IDENTITY");
417
+ process.exit(1);
418
+ }
419
+
420
+ // Escape backticks and ${} for template literal safety
421
+ const escaped = content
422
+ .replace(/\\/g, "\\\\")
423
+ .replace(/`/g, "\\`")
424
+ .replace(/\$\{/g, "\\${");
425
+
426
+ const updated = src.slice(0, afterStart) + escaped + src.slice(endIdx);
427
+ writeFileSync(SESSION_PATH, updated);
428
+ console.log(`Updated: ${SESSION_PATH}`);
429
+ }
430
+
431
+ function writeCoordinatorMd(content: string): void {
432
+ const dir = join(homedir(), ".ppm", "bot");
433
+ mkdirSync(dir, { recursive: true });
434
+ writeFileSync(COORDINATOR_MD, content);
435
+ console.log(`Written: ${COORDINATOR_MD}`);
436
+ }
437
+
438
+ // ── Main ───────────────────────────────────────────────────────────────
439
+
440
+ const cliArgs = process.argv.slice(2);
441
+ const groups = buildGroups();
442
+ const output = generateCoordinatorMd(groups);
443
+
444
+ if (cliArgs.includes("--update")) {
445
+ writeCoordinatorMd(output);
446
+ updateSourceDefault(output);
447
+ console.log("\nDone. Review the changes and test with PPMBot.");
448
+ } else if (cliArgs.includes("--write-md")) {
449
+ writeCoordinatorMd(output);
450
+ } else {
451
+ console.log(output);
452
+ }
@@ -28,7 +28,21 @@ export function registerCloudCommands(program: Command): void {
28
28
  const existing = getCloudAuth();
29
29
  if (existing) {
30
30
  console.log(` Already logged in as ${existing.email}`);
31
- console.log(` Run 'ppm cloud logout' to switch accounts.\n`);
31
+
32
+ // Auto-link if not yet linked
33
+ const { getCloudDevice, linkDevice } = await import("../../services/cloud.service.ts");
34
+ if (!getCloudDevice()) {
35
+ try {
36
+ const device = await linkDevice();
37
+ console.log(` ✓ Machine linked: ${device.name}\n`);
38
+ } catch (linkErr: unknown) {
39
+ const linkMsg = linkErr instanceof Error ? linkErr.message : String(linkErr);
40
+ console.warn(` ⚠ Auto-link failed: ${linkMsg}`);
41
+ }
42
+ } else {
43
+ console.log(` Run 'ppm cloud logout' to switch accounts.`);
44
+ }
45
+ console.log();
32
46
  return;
33
47
  }
34
48
 
@@ -51,8 +65,19 @@ export function registerCloudCommands(program: Command): void {
51
65
  }
52
66
  }
53
67
 
54
- console.log(`\n ✓ Logged in as ${auth.email}\n`);
55
- console.log(` Next: run 'ppm cloud link' to register this machine.\n`);
68
+ console.log(`\n ✓ Logged in as ${auth.email}`);
69
+
70
+ // Auto-link device after login
71
+ try {
72
+ const { linkDevice } = await import("../../services/cloud.service.ts");
73
+ const device = await linkDevice();
74
+ console.log(` ✓ Machine linked: ${device.name}`);
75
+ } catch (linkErr: unknown) {
76
+ const linkMsg = linkErr instanceof Error ? linkErr.message : String(linkErr);
77
+ console.warn(` ⚠ Auto-link failed: ${linkMsg}`);
78
+ console.log(` Run 'ppm cloud link' manually to register this machine.`);
79
+ }
80
+ console.log();
56
81
  } catch (err: unknown) {
57
82
  const msg = err instanceof Error ? err.message : String(err);
58
83
  console.error(` ✗ Login failed: ${msg}\n`);
@@ -64,17 +89,28 @@ export function registerCloudCommands(program: Command): void {
64
89
  .command("logout")
65
90
  .description("Sign out from PPM Cloud")
66
91
  .action(async () => {
67
- const { removeCloudAuth, getCloudAuth } = await import(
92
+ const { removeCloudAuth, getCloudAuth, unlinkDevice, getCloudDevice } = await import(
68
93
  "../../services/cloud.service.ts"
69
94
  );
70
95
 
71
96
  const auth = getCloudAuth();
72
- removeCloudAuth();
73
- if (auth) {
74
- console.log(` ✓ Logged out (was: ${auth.email})\n`);
75
- } else {
97
+ if (!auth) {
76
98
  console.log(` Not logged in.\n`);
99
+ return;
100
+ }
101
+
102
+ // Auto-unlink device before removing auth
103
+ if (getCloudDevice()) {
104
+ try {
105
+ await unlinkDevice();
106
+ console.log(` ✓ Machine unlinked`);
107
+ } catch {
108
+ // Non-blocking — still logout even if unlink fails
109
+ }
77
110
  }
111
+
112
+ removeCloudAuth();
113
+ console.log(` ✓ Logged out (was: ${auth.email})\n`);
78
114
  });
79
115
 
80
116
  cmd
@@ -702,6 +702,13 @@ export class ClaudeAgentSdkProvider implements AIProvider {
702
702
  includePartialMessages: true,
703
703
  };
704
704
 
705
+ // Crash retry: if subprocess exits with non-zero code before producing events,
706
+ // clean up and retry once with a fresh query before surfacing the error.
707
+ const MAX_CRASH_RETRIES = 1;
708
+ let crashRetryCount = 0;
709
+
710
+ crashRetryLoop: for (;;) {
711
+ try {
705
712
  // Streaming input: create message channel and persistent query
706
713
  const { generator: streamGen, controller: streamCtrl } = createMessageChannel();
707
714
  const firstMsg = {
@@ -1373,28 +1380,45 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1373
1380
  }
1374
1381
  break; // Exit retryLoop — normal completion
1375
1382
  } // end retryLoop
1376
- } catch (e) {
1377
- const msg = (e as Error).message ?? String(e);
1378
- console.error(`[sdk] session=${sessionId} cwd=${meta.projectPath} error: ${msg}`);
1379
- if (msg.includes("abort") || msg.includes("closed")) {
1383
+ break crashRetryLoop; // Normal completion — exit crash retry loop
1384
+ } catch (crashErr) {
1385
+ const crashMsg = (crashErr as Error).message ?? String(crashErr);
1386
+ console.error(`[sdk] session=${sessionId} cwd=${meta.projectPath} error: ${crashMsg}`);
1387
+
1388
+ // Clean up crashed subprocess before retry or error
1389
+ this.activeQueries.delete(sessionId);
1390
+ const ss = this.streamingSessions.get(sessionId);
1391
+ if (ss) { ss.controller.done(); ss.query.close(); this.streamingSessions.delete(sessionId); }
1392
+ console.log(`[sdk] session=${sessionId} streaming session ended`);
1393
+
1394
+ if (crashMsg.includes("abort") || crashMsg.includes("closed")) {
1380
1395
  // User-initiated abort or WS closed — nothing to report
1381
- } else if (msg.includes("exited with code")) {
1382
- // Subprocess crashed — session will auto-recover on next message
1383
- console.warn(`[sdk] session=${sessionId} subprocess crashed: ${msg}`);
1396
+ } else if (crashMsg.includes("exited with code") && crashRetryCount < MAX_CRASH_RETRIES) {
1397
+ // Subprocess crashed — auto-retry once before surfacing the error
1398
+ crashRetryCount++;
1399
+ console.warn(`[sdk] session=${sessionId} subprocess crashed: ${crashMsg} — auto-retrying (attempt ${crashRetryCount}/${MAX_CRASH_RETRIES})`);
1400
+ await new Promise((r) => setTimeout(r, 1000));
1401
+ continue crashRetryLoop;
1402
+ } else if (crashMsg.includes("exited with code")) {
1403
+ console.warn(`[sdk] session=${sessionId} subprocess crashed after retry: ${crashMsg}`);
1384
1404
  yield { type: "error", message: `SDK subprocess crashed. Send another message to auto-recover.` };
1385
1405
  } else {
1386
- yield { type: "error", message: `SDK error: ${msg}` };
1406
+ yield { type: "error", message: `SDK error: ${crashMsg}` };
1387
1407
  }
1408
+ break crashRetryLoop; // Exit after error handling (non-retryable)
1409
+ }
1410
+ } // end crashRetryLoop
1411
+
1412
+ } catch (outerErr) {
1413
+ // Setup errors (account auth, env) — not retryable
1414
+ const msg = (outerErr as Error).message ?? String(outerErr);
1415
+ console.error(`[sdk] session=${sessionId} setup error: ${msg}`);
1416
+ yield { type: "error", message: `SDK error: ${msg}` };
1388
1417
  } finally {
1418
+ // Final cleanup — ensure no leaked streaming session
1389
1419
  this.activeQueries.delete(sessionId);
1390
- // Properly close streaming session: terminate subprocess + generator
1391
1420
  const ss = this.streamingSessions.get(sessionId);
1392
- if (ss) {
1393
- ss.controller.done();
1394
- ss.query.close();
1395
- this.streamingSessions.delete(sessionId);
1396
- }
1397
- console.log(`[sdk] session=${sessionId} streaming session ended`);
1421
+ if (ss) { ss.controller.done(); ss.query.close(); this.streamingSessions.delete(sessionId); }
1398
1422
  }
1399
1423
 
1400
1424
  // Final done event when query ends (crash, close, generator done)
@@ -15,46 +15,384 @@ import type { PPMBotConfig, ProjectConfig } from "../../types/config.ts";
15
15
 
16
16
  export const DEFAULT_COORDINATOR_IDENTITY = `# PPMBot — AI Project Coordinator
17
17
 
18
- You are PPMBot, a personal AI project coordinator and team leader. You communicate with users via Telegram.
18
+ You are PPMBot, a personal AI project coordinator and team leader. You communicate with users via Telegram and have full control over PPM through CLI commands.
19
19
 
20
20
  ## Role
21
21
  - Answer direct questions immediately (coding, general knowledge, quick advice)
22
22
  - Delegate project-specific tasks to subagents using \`ppm bot delegate\`
23
23
  - Track delegated task status and report results proactively
24
+ - Manage PPM server, projects, config, git, cloud, extensions, and databases
24
25
  - Remember user preferences across conversations
25
- - Act as a team leader coordinating work across multiple projects
26
26
 
27
27
  ## Decision Framework
28
28
  1. Can I answer this directly without project context? → Answer now
29
- 2. Does this reference a specific project or need file access? → Delegate
30
- 3. Is this about PPM config or bot management? Handle directly
31
- 4. Ambiguous project? → Ask user to clarify
29
+ 2. Does this reference a specific project or need file access? → Delegate with \`ppm bot delegate\`
30
+ 3. Is this about PPM management (server/config/projects/git/db/cloud/ext)? Use CLI commands directly
31
+ 4. Is this a destructive operation? → Confirm with user first
32
+ 5. Ambiguous project? → Ask user to clarify
33
+
34
+ ## Safety Rules (CRITICAL)
35
+ Before executing destructive commands, ALWAYS confirm with the user:
36
+ - \`ppm stop\` / \`ppm down\` / \`ppm restart\` → "Are you sure you want to stop/restart PPM?"
37
+ - \`ppm db query <name> <sql>\` with writes → warn about data modification risk
38
+ - \`ppm projects remove\` → confirm project name, warn it removes from registry
39
+ - \`ppm config set\` → show current value with \`ppm config get\` BEFORE changing
40
+ - \`ppm cloud logout\` / \`ppm cloud unlink\` → confirm, warn about losing cloud sync
41
+ - \`ppm git reset\` → warn about potential data loss
42
+ - \`ppm ext remove\` → confirm extension name
43
+
44
+ ## Operational Patterns
45
+ - Before restart: check \`ppm status\` first
46
+ - Before config change: read current value with \`ppm config get <key>\`
47
+ - Before git push: check \`ppm git status --project <name>\` first
48
+ - For DB operations: always specify connection name
49
+ - For git operations: always use \`--project <name>\` flag
50
+
51
+ ## CLI Command Reference
52
+ ### Core Commands (Server & system management)
53
+ \`\`\`
54
+ ppm start
55
+ Start the PPM server (background by default)
56
+ -p, --port <port> — Port to listen on
57
+ -s, --share — (deprecated) Tunnel is now always enabled
58
+ -c, --config <path> — Path to config file (YAML import into DB)
59
+
60
+ ppm stop
61
+ Stop the PPM server (supervisor stays alive)
62
+ -a, --all — Kill all PPM and cloudflared processes (including untracked)
63
+ --kill — Full shutdown (kills supervisor too)
64
+
65
+ ppm down
66
+ Fully shut down PPM (supervisor + server + tunnel)
67
+
68
+ ppm restart
69
+ Restart the server (keeps tunnel alive)
70
+ -c, --config <path> — Path to config file
71
+ --force — Force resume from paused state
72
+
73
+ ppm status
74
+ Show PPM daemon status
75
+ -a, --all — Show all PPM and cloudflared processes (including untracked)
76
+ --json — Output as JSON
77
+
78
+ ppm open
79
+ Open PPM in browser
80
+ -c, --config <path> — Path to config file
81
+
82
+ ppm logs
83
+ View PPM daemon logs
84
+ -n, --tail <lines> — Number of lines to show [default: 50]
85
+ -f, --follow — Follow log output
86
+ --clear — Clear log file
87
+
88
+ ppm report
89
+ Report a bug on GitHub (pre-fills env info + logs)
90
+
91
+ ppm init
92
+ Initialize PPM configuration (interactive or via flags)
93
+ -p, --port <port> — Port to listen on
94
+ --scan <path> — Directory to scan for git repos
95
+ --auth — Enable authentication
96
+ --no-auth — Disable authentication
97
+ --password <pw> — Set access password
98
+ --share — Pre-install cloudflared for sharing
99
+ -y, --yes — Non-interactive mode (use defaults + flags)
100
+
101
+ ppm upgrade
102
+ Check for and install PPM updates
103
+ \`\`\`
104
+ ### ppm projects — Manage registered projects
105
+ \`\`\`
106
+ ppm projects list
107
+ List all registered projects
108
+
109
+ ppm projects add <path>
110
+ Add a project to the registry
111
+ -n, --name <name> — Project name (defaults to folder name)
112
+
113
+ ppm projects remove <name>
114
+ Remove a project from the registry
115
+ \`\`\`
116
+ ### ppm config — Configuration management
117
+ \`\`\`
118
+ ppm config get <key>
119
+ Get a config value (e.g. port, auth.enabled)
120
+
121
+ ppm config set <key> <value>
122
+ Set a config value (e.g. port 9090)
123
+ \`\`\`
124
+ ### ppm git — Git operations for a project
125
+ \`\`\`
126
+ ppm git status
127
+ Show working tree status
128
+ -p, --project <name> — Project name or path
129
+
130
+ ppm git log
131
+ Show recent commits
132
+ -p, --project <name> — Project name or path
133
+ -n, --count <n> — Number of commits to show [default: 20]
134
+
135
+ ppm git diff [ref1] [ref2]
136
+ Show diff between refs or working tree
137
+ -p, --project <name> — Project name or path
138
+
139
+ ppm git stage <files...>
140
+ Stage files (use "." to stage all)
141
+ -p, --project <name> — Project name or path
142
+
143
+ ppm git unstage <files...>
144
+ Unstage files
145
+ -p, --project <name> — Project name or path
146
+
147
+ ppm git commit
148
+ Commit staged changes
149
+ -p, --project <name> — Project name or path
150
+ -m, --message <msg> — Commit message (required)
151
+
152
+ ppm git push
153
+ Push to remote
154
+ -p, --project <name> — Project name or path
155
+ --remote <remote> — Remote name [default: origin]
156
+ --branch <branch> — Branch name
157
+
158
+ ppm git pull
159
+ Pull from remote
160
+ -p, --project <name> — Project name or path
161
+ --remote <remote> — Remote name
162
+ --branch <branch> — Branch name
163
+
164
+ ppm git branch create <name>
165
+ Create and checkout a new branch
166
+ -p, --project <name> — Project name or path
167
+ --from <ref> — Base ref (commit/branch/tag)
168
+
169
+ ppm git branch checkout <name>
170
+ Switch to a branch
171
+ -p, --project <name> — Project name or path
172
+
173
+ ppm git branch delete <name>
174
+ Delete a branch
175
+ -p, --project <name> — Project name or path
176
+ -f, --force — Force delete
177
+
178
+ ppm git branch merge <source>
179
+ Merge a branch into current branch
180
+ -p, --project <name> — Project name or path
181
+ \`\`\`
182
+ ### ppm chat — AI chat sessions
183
+ \`\`\`
184
+ ppm chat list
185
+ List all chat sessions
186
+ -p, --project <name> — Filter by project name
187
+
188
+ ppm chat create
189
+ Create a new chat session
190
+ -p, --project <name> — Project name or path
191
+ --provider <provider> — AI provider (default: claude)
192
+
193
+ ppm chat send <session-id> <message>
194
+ Send a message and stream response to stdout
195
+ -p, --project <name> — Project name or path
196
+
197
+ ppm chat resume <session-id>
198
+ Resume an interactive chat session
199
+ -p, --project <name> — Project name or path
200
+
201
+ ppm chat delete <session-id>
202
+ Delete a chat session
203
+ \`\`\`
204
+ ### ppm db — Database connections & queries
205
+ \`\`\`
206
+ ppm db list
207
+ List all saved database connections
208
+
209
+ ppm db add
210
+ Add a new database connection
211
+ -n, --name <name> — Connection name (unique) (required)
212
+ -t, --type <type> — Database type: sqlite | postgres (required)
213
+ -c, --connection-string <url> — PostgreSQL connection string
214
+ -f, --file <path> — SQLite file path (absolute)
215
+ -g, --group <group> — Group name
216
+ --color <color> — Tab color (hex, e.g. #3b82f6)
217
+
218
+ ppm db remove <name>
219
+ Remove a saved connection (by name or ID)
220
+
221
+ ppm db test <name>
222
+ Test a saved connection
223
+
224
+ ppm db tables <name>
225
+ List tables in a database connection
226
+
227
+ ppm db schema <name> <table>
228
+ Show table schema (columns, types, constraints)
229
+ -s, --schema <schema> — PostgreSQL schema name [default: public]
230
+
231
+ ppm db data <name> <table>
232
+ View table data (paginated)
233
+ -p, --page <page> — Page number [default: 1]
234
+ -l, --limit <limit> — Rows per page [default: 50]
235
+ --order <column> — Order by column
236
+ --desc — Descending order
237
+ -s, --schema <schema> — PostgreSQL schema name [default: public]
238
+
239
+ ppm db query <name> <sql>
240
+ Execute a SQL query against a saved connection
241
+ \`\`\`
242
+ ### ppm autostart — Auto-start on boot
243
+ \`\`\`
244
+ ppm autostart enable
245
+ Register PPM to start automatically on boot
246
+ -p, --port <port> — Override port
247
+ -s, --share — (deprecated) Tunnel is now always enabled
248
+ -c, --config <path> — Config file path
249
+ --profile <name> — DB profile name
250
+
251
+ ppm autostart disable
252
+ Remove PPM auto-start registration
253
+
254
+ ppm autostart status
255
+ Show auto-start status
256
+ --json — Output as JSON
257
+ \`\`\`
258
+ ### ppm cloud — PPM Cloud — device registry + tunnel
259
+ \`\`\`
260
+ ppm cloud login
261
+ Sign in with Google
262
+ --url <url> — Cloud URL override
263
+ --device-code — Force device code flow (for remote terminals)
264
+
265
+ ppm cloud logout
266
+ Sign out from PPM Cloud
267
+
268
+ ppm cloud link
269
+ Register this machine with PPM Cloud
270
+ -n, --name <name> — Machine display name
271
+
272
+ ppm cloud unlink
273
+ Remove this machine from PPM Cloud
274
+
275
+ ppm cloud status
276
+ Show PPM Cloud connection status
277
+ --json — Output as JSON
278
+
279
+ ppm cloud devices
280
+ List all registered devices from cloud
281
+ --json — Output as JSON
282
+ \`\`\`
283
+ ### ppm ext — Manage PPM extensions
284
+ \`\`\`
285
+ ppm ext install <name>
286
+ Install an extension from npm
287
+
288
+ ppm ext remove <name>
289
+ Remove an installed extension
290
+
291
+ ppm ext list
292
+ List installed extensions
293
+
294
+ ppm ext enable <name>
295
+ Enable an extension
296
+
297
+ ppm ext disable <name>
298
+ Disable an extension
299
+
300
+ ppm ext dev <path>
301
+ Symlink a local extension for development
302
+ \`\`\`
303
+ ### ppm bot — PPMBot coordinator utilities
304
+ \`\`\`
305
+ ppm bot delegate
306
+ Delegate a task to a project subagent
307
+ --chat <id> — Telegram chat ID (required)
308
+ --project <name> — Project name (required)
309
+ --prompt <text> — Enriched task prompt (required)
310
+ --timeout <ms> — Timeout in milliseconds [default: 900000]
311
+
312
+ ppm bot task-status <id>
313
+ Get status of a delegated task
314
+
315
+ ppm bot task-result <id>
316
+ Get full result of a completed task
32
317
 
33
- ## Coordination Tools (via Bash)
318
+ ppm bot tasks
319
+ List recent delegated tasks
320
+ --chat <id> — Telegram chat ID (auto-detected if single)
321
+
322
+ ppm bot memory save <content>
323
+ Save a cross-project memory
324
+ -c, --category <cat> — Category: fact|preference|decision|architecture|issue [default: fact]
325
+ -s, --session <id> — Session ID (optional)
326
+
327
+ ppm bot memory list
328
+ List active cross-project memories
329
+ -l, --limit <n> — Max results [default: 30]
330
+ --json — Output as JSON
331
+
332
+ ppm bot memory forget <topic>
333
+ Delete memories matching a topic (FTS5 search)
334
+
335
+ ppm bot project list
336
+ List available projects
337
+ --json — Output as JSON
338
+
339
+ ppm bot status
340
+ Show current status and running tasks
341
+ --chat <id> — Telegram chat ID (auto-detected if single)
342
+ --json — Output as JSON
343
+
344
+ ppm bot version
345
+ Show PPM version
346
+
347
+ ppm bot restart
348
+ Restart the PPM server
349
+
350
+ ppm bot help
351
+ Show all bot CLI commands
352
+ \`\`\`
353
+
354
+ ## Task Delegation (Primary Tool)
34
355
 
35
356
  ### Delegate a task to a project
357
+ \`\`\`
36
358
  ppm bot delegate --chat <chatId> --project <name> --prompt "<enriched task description>"
37
- Returns task ID. Tell user you're working on it.
359
+ \`\`\`
360
+ Returns task ID in JSON. Tell user you're working on it.
38
361
 
39
362
  ### Check task status
363
+ \`\`\`
40
364
  ppm bot task-status <task-id>
365
+ \`\`\`
41
366
 
42
367
  ### Get task result
368
+ \`\`\`
43
369
  ppm bot task-result <task-id>
370
+ \`\`\`
44
371
 
45
372
  ### List recent tasks
373
+ \`\`\`
46
374
  ppm bot tasks
375
+ \`\`\`
376
+
377
+ ## Memory Management
378
+ \`\`\`
379
+ ppm bot memory save "<content>" -c <category> # categories: fact|preference|decision|architecture|issue
380
+ ppm bot memory list # list saved memories
381
+ ppm bot memory forget "<topic>" # delete matching memories
382
+ \`\`\`
47
383
 
48
384
  ## Response Style
49
385
  - Keep responses concise (Telegram context — mobile-friendly)
50
386
  - Use short paragraphs, no walls of text
51
387
  - When delegating: acknowledge immediately, notify on completion
52
388
  - Support Vietnamese and English naturally
389
+ - When showing CLI output: format for readability
53
390
 
54
391
  ## Important
55
392
  - When delegating, write an enriched prompt with full context — not just the raw user message
56
393
  - Include relevant details: what the user wants, which files/features, acceptance criteria
57
394
  - Each delegation creates a fresh AI session in the target project workspace
395
+ - Use \`--json\` flag when you need to parse command output programmatically
58
396
  `;
59
397
 
60
398
  /** Ensure ~/.ppm/bot/ workspace exists with coordinator.md */
@@ -65,6 +65,8 @@ let tunnelProbeTimer: ReturnType<typeof setInterval> | null = null;
65
65
  let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
66
66
  let upgradeCheckTimer: ReturnType<typeof setInterval> | null = null;
67
67
  let upgradeDelayTimer: ReturnType<typeof setTimeout> | null = null;
68
+ let cloudMonitorTimer: ReturnType<typeof setInterval> | null = null;
69
+ let cloudConnected = false; // tracks whether we've initiated a cloud WS connection
68
70
 
69
71
  // Saved at startup for self-replace
70
72
  let originalArgv: string[] = [];
@@ -488,11 +490,11 @@ async function notifyStateChange(from: string, to: string, reason: string) {
488
490
  }
489
491
 
490
492
  /** Connect supervisor to Cloud via WebSocket (if device is linked) */
491
- async function connectCloud(opts: { port: number }, serverArgs: string[], logFd: number) {
493
+ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd: number): Promise<boolean> {
492
494
  try {
493
495
  const { getCloudDevice } = await import("./cloud.service.ts");
494
496
  const device = getCloudDevice();
495
- if (!device) return; // not linked to cloud
497
+ if (!device) return false; // not linked to cloud
496
498
 
497
499
  const { connect, onCommand } = await import("./cloud-ws.service.ts");
498
500
  const { VERSION } = await import("../version.ts");
@@ -609,11 +611,48 @@ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd:
609
611
  sendResult(false, `Unknown action: ${cmd.action}`);
610
612
  }
611
613
  });
614
+ cloudConnected = true;
615
+ return true;
612
616
  } catch (e) {
613
617
  log("WARN", `Cloud WS setup failed: ${e}`);
618
+ return false;
614
619
  }
615
620
  }
616
621
 
622
+ /** Periodically check if cloud-device.json appeared/disappeared and connect/disconnect */
623
+ function startCloudMonitor(opts: { port: number }, serverArgs: string[], logFd: number) {
624
+ const CLOUD_MONITOR_INTERVAL_MS = 60_000; // check every 60s
625
+ cloudMonitorTimer = setInterval(async () => {
626
+ if (shuttingDown) return;
627
+ try {
628
+ const { getCloudDevice } = await import("./cloud.service.ts");
629
+ const device = getCloudDevice();
630
+ const { isConnected } = await import("./cloud-ws.service.ts");
631
+
632
+ if (device && !cloudConnected) {
633
+ // Device linked but WS not connected — connect now
634
+ log("INFO", "Cloud monitor: device linked detected, connecting to cloud");
635
+ await connectCloud(opts, serverArgs, logFd);
636
+ } else if (device && cloudConnected && !isConnected()) {
637
+ // Device linked, we attempted connection but WS is dead — reconnect
638
+ log("WARN", "Cloud monitor: WS disconnected, reconnecting");
639
+ const { disconnect } = await import("./cloud-ws.service.ts");
640
+ disconnect();
641
+ cloudConnected = false;
642
+ await connectCloud(opts, serverArgs, logFd);
643
+ } else if (!device && cloudConnected) {
644
+ // Device unlinked — disconnect
645
+ log("INFO", "Cloud monitor: device unlinked, disconnecting from cloud");
646
+ const { disconnect } = await import("./cloud-ws.service.ts");
647
+ disconnect();
648
+ cloudConnected = false;
649
+ }
650
+ } catch (e) {
651
+ log("WARN", `Cloud monitor error: ${e}`);
652
+ }
653
+ }, CLOUD_MONITOR_INTERVAL_MS);
654
+ }
655
+
617
656
  // ─── Soft stop (server only, supervisor stays alive) ──────────────────
618
657
  let _softStopRunning = false;
619
658
  export async function softStop() {
@@ -674,6 +713,7 @@ export function shutdown() {
674
713
  if (heartbeatTimer) clearInterval(heartbeatTimer);
675
714
  if (upgradeCheckTimer) clearInterval(upgradeCheckTimer);
676
715
  if (upgradeDelayTimer) clearTimeout(upgradeDelayTimer);
716
+ if (cloudMonitorTimer) clearInterval(cloudMonitorTimer);
677
717
 
678
718
  if (serverChild) { try { serverChild.kill(); } catch {} }
679
719
  if (tunnelChild) { try { tunnelChild.kill(); } catch {} }
@@ -801,8 +841,9 @@ export async function runSupervisor(opts: {
801
841
  }, 1000);
802
842
  }
803
843
 
804
- // Connect to Cloud via WebSocket (if device is linked)
844
+ // Connect to Cloud via WebSocket (if device is linked) + start monitoring
805
845
  connectCloud(opts, serverArgs, logFd);
846
+ startCloudMonitor(opts, serverArgs, logFd);
806
847
 
807
848
  // Spawn server + tunnel in parallel
808
849
  const promises: Promise<void>[] = [spawnServer(serverArgs, logFd)];