@hienlh/ppm 0.12.9 → 0.12.11

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.11] - 2026-04-21
4
+
5
+ ### Fixed
6
+ - **Upgrade wiped user config back to defaults**: `configService.importFromYaml()` unconditionally overwrote SQLite config keys with `{...DEFAULT_CONFIG, ...yaml}` on every `load()` call. The upgrade path via supervisor `selfReplace()` re-ran the saved `originalArgv` — which contained `-c <yaml>` baked into systemd/launchd `ExecStart` — re-triggering the overwrite and resetting user config to defaults + stale YAML contents
7
+
8
+ ### Removed
9
+ - **Legacy YAML config support**: Fully migrated to SQLite; removed `-c/--config <path>` CLI flag (from `start`, `restart`, `open`, `autostart enable`), YAML import/migration code, `configPath` in autostart ExecStart, and `__serve__`/`__supervise__` positional config slot. `js-yaml` dep retained (skill frontmatter only)
10
+
11
+ ## [0.12.10] - 2026-04-21
12
+
13
+ ### Fixed
14
+ - **Auth retry stuck across turns**: `authRetried` was a per-session boolean, so the second 401 in any subsequent turn skipped token refresh and the stream hung. Replaced with per-turn counter (`authRetryCount`, max 2) that resets on each successful turn, so every turn gets a fresh refresh budget
15
+ - **Multi-attempt auth recovery**: Centralized auth-error recovery in `recoverFromAuthError` — attempt 1 refreshes the current account's OAuth token, attempt 2 switches to a different active account, only then surfaces an error. All three 401 detection paths (`api_retry`, assistant text, result) share this logic
16
+ - **api_retry 401 stall**: When no recovery path remains, break immediately instead of falling through to SDK's internal 10x retry (which wasted ~5 minutes before surfacing the error)
17
+
3
18
  ## [0.12.9] - 2026-04-21
4
19
 
5
20
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.12.9",
3
+ "version": "0.12.11",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -11,12 +11,11 @@ export function registerAutoStartCommands(program: Command): void {
11
11
  .description("Register PPM to start automatically on boot")
12
12
  .option("-p, --port <port>", "Override port")
13
13
  .option("-s, --share", "(deprecated) Tunnel is now always enabled")
14
- .option("-c, --config <path>", "Config file path")
15
14
  .option("--profile <name>", "DB profile name")
16
15
  .action(async (options) => {
17
16
  const { enableAutoStart } = await import("../../services/autostart-register.ts");
18
17
 
19
- configService.load(options.config);
18
+ configService.load();
20
19
  const port = parseInt(options.port ?? String(configService.get("port")), 10);
21
20
  const host = configService.get("host") ?? "0.0.0.0";
22
21
 
@@ -24,7 +23,6 @@ export function registerAutoStartCommands(program: Command): void {
24
23
  port,
25
24
  host,
26
25
  share: !!options.share,
27
- configPath: options.config,
28
26
  profile: options.profile,
29
27
  };
30
28
 
@@ -1,7 +1,5 @@
1
1
  import { resolve, basename } from "node:path";
2
2
  import { homedir } from "node:os";
3
- import { existsSync } from "node:fs";
4
- import { getPpmDir } from "../../services/ppm-dir.ts";
5
3
  import { input, confirm, select, password } from "@inquirer/prompts";
6
4
  import { configService } from "../../services/config.service.ts";
7
5
  import { projectService } from "../../services/project.service.ts";
@@ -19,15 +17,14 @@ export interface InitOptions {
19
17
  yes?: boolean;
20
18
  }
21
19
 
22
- /** Check if config already exists */
23
- /** Check if config already exists (DB or legacy YAML) */
20
+ /** Check if config already exists in SQLite */
24
21
  export function hasConfig(): boolean {
25
22
  try {
26
23
  const dbConfig = getAllConfig();
27
- if (Object.keys(dbConfig).length > 0) return true;
28
- } catch {}
29
- const globalConfig = resolve(getPpmDir(), "config.yaml");
30
- return existsSync(globalConfig);
24
+ return Object.keys(dbConfig).length > 0;
25
+ } catch {
26
+ return false;
27
+ }
31
28
  }
32
29
 
33
30
  export async function initProject(options: InitOptions = {}) {
@@ -8,7 +8,7 @@ const restartingFlag = () => resolve(getPpmDir(), ".restarting");
8
8
  const restartResult = () => resolve(getPpmDir(), ".restart-result");
9
9
 
10
10
  /** Restart only the server process, keeping the tunnel alive */
11
- export async function restartServer(options: { config?: string; force?: boolean }) {
11
+ export async function restartServer(options: { force?: boolean }) {
12
12
  // Ignore SIGHUP so this process survives when PPM terminal dies
13
13
  process.on("SIGHUP", () => {});
14
14
 
@@ -114,7 +114,7 @@ export async function restartServer(options: { config?: string; force?: boolean
114
114
  : resolve(import.meta.dir, "../../server/index.ts");
115
115
 
116
116
  const { configService } = await import("../../services/config.service.ts");
117
- configService.load(options.config);
117
+ configService.load();
118
118
  const port = status.port as number ?? configService.get("port");
119
119
  const host = status.host as string ?? configService.get("host");
120
120
 
@@ -133,7 +133,6 @@ export async function restartServer(options: { config?: string; force?: boolean
133
133
  // terminal (and its process group) to receive SIGHUP.
134
134
  const params = JSON.stringify({
135
135
  serverPid, port, host, serverScript,
136
- config: options.config ?? "",
137
136
  statusFile: statusFile(),
138
137
  pidFile: pidFile(),
139
138
  restartingFlag: restartingFlag(),
@@ -204,8 +203,8 @@ async function main() {
204
203
  // Compiled binary: execPath IS the server, no "run script" needed
205
204
  const isCompiled = !process.execPath.includes("bun");
206
205
  const serverArgs = isCompiled
207
- ? ["__serve__", String(P.port), P.host, P.config].filter(Boolean)
208
- : ["run", P.serverScript, "__serve__", String(P.port), P.host, P.config].filter(Boolean);
206
+ ? ["__serve__", String(P.port), P.host]
207
+ : ["run", P.serverScript, "__serve__", String(P.port), P.host];
209
208
 
210
209
  if (process.platform === "win32") {
211
210
  const bunExe = process.execPath.replace(/\\\\/g, "\\\\\\\\");
package/src/index.ts CHANGED
@@ -18,15 +18,12 @@ program
18
18
  .description("Start the PPM server (background by default)")
19
19
  .option("-p, --port <port>", "Port to listen on")
20
20
  .option("-s, --share", "(deprecated) Tunnel is now always enabled")
21
- .option("-c, --config <path>", "Path to config file (YAML import into DB)")
22
21
  .option("--profile <name>", "DB profile name (e.g. 'dev' → ppm.dev.db)")
23
22
  .action(async (options) => {
24
23
  // Set DB profile before any DB access
25
24
  const { setDbProfile } = await import("./services/db.service.ts");
26
25
  if (options.profile) {
27
26
  setDbProfile(options.profile);
28
- } else if (options.config && /dev/i.test(options.config)) {
29
- setDbProfile("dev");
30
27
  }
31
28
  // Auto-init on first run
32
29
  const { hasConfig, initProject } = await import("./cli/commands/init.ts");
@@ -58,7 +55,6 @@ program
58
55
  program
59
56
  .command("restart")
60
57
  .description("Restart the server (keeps tunnel alive)")
61
- .option("-c, --config <path>", "Path to config file")
62
58
  .option("--force", "Force resume from paused state")
63
59
  .action(async (options) => {
64
60
  const { restartServer } = await import("./cli/commands/restart.ts");
@@ -78,7 +74,6 @@ program
78
74
  program
79
75
  .command("open")
80
76
  .description("Open PPM in browser")
81
- .option("-c, --config <path>", "Path to config file")
82
77
  .action(async () => {
83
78
  const { openBrowser } = await import("./cli/commands/open.ts");
84
79
  await openBrowser();
@@ -141,6 +141,57 @@ export class ClaudeAgentSdkProvider implements AIProvider {
141
141
  * Auth env vars are ALWAYS explicitly set so the SDK subprocess never falls back
142
142
  * to reading the project's .env file (which may contain unrelated API keys).
143
143
  */
144
+ /**
145
+ * Recover from a 401/auth error. Strategy:
146
+ * Attempt 1 (authRetryCount === 0): refresh current account's OAuth token.
147
+ * Attempt 2+ (authRetryCount >= 1): switch to a different active account.
148
+ * Yields account_retry events so the FE can update streaming state.
149
+ * Returns the new account + incremented retry count, or null if no recovery path remains.
150
+ * Caller is responsible for rebuilding the SDK query with the returned account.
151
+ */
152
+ private async *recoverFromAuthError(opts: {
153
+ sessionId: string;
154
+ account: AccountWithTokens;
155
+ authRetryCount: number;
156
+ maxRetries: number;
157
+ context: string;
158
+ }): AsyncGenerator<ChatEvent, { account: AccountWithTokens; newRetryCount: number } | null, void> {
159
+ const { sessionId, account, authRetryCount, maxRetries, context } = opts;
160
+
161
+ // Attempt 1: refresh the current account's token
162
+ if (authRetryCount === 0) {
163
+ try {
164
+ await accountService.refreshAccessToken(account.id, false, true);
165
+ const refreshed = accountService.getWithTokens(account.id);
166
+ if (refreshed) {
167
+ const label = refreshed.label ?? refreshed.email ?? "Unknown";
168
+ console.log(`[sdk] session=${sessionId} (${context}) OAuth token refreshed for ${account.id} (${label}) — retrying`);
169
+ yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshed.id, accountLabel: label };
170
+ return { account: refreshed, newRetryCount: 1 };
171
+ }
172
+ } catch (err) {
173
+ console.error(`[sdk] session=${sessionId} (${context}) OAuth refresh failed:`, err);
174
+ }
175
+ // Refresh failed — fall through to account switch
176
+ }
177
+
178
+ // Attempt 2+: switch to a different active account
179
+ if (authRetryCount < maxRetries) {
180
+ accountSelector.onAuthError(account.id);
181
+ const nextAcc = accountSelector.next();
182
+ if (nextAcc && nextAcc.id !== account.id) {
183
+ const label = nextAcc.label ?? nextAcc.email ?? "Unknown";
184
+ console.log(`[sdk] session=${sessionId} (${context}) switching to account ${nextAcc.id} (${label}) after auth failure`);
185
+ yield { type: "account_retry" as const, reason: "Switching account", accountId: nextAcc.id, accountLabel: label };
186
+ return { account: nextAcc, newRetryCount: authRetryCount + 1 };
187
+ }
188
+ console.warn(`[sdk] session=${sessionId} (${context}) no alternate account available for switch`);
189
+ } else {
190
+ console.warn(`[sdk] session=${sessionId} (${context}) auth retry budget exhausted (${authRetryCount}/${maxRetries})`);
191
+ }
192
+ return null;
193
+ }
194
+
144
195
  private buildQueryEnv(
145
196
  _projectPath: string | undefined,
146
197
  account: { id: string; accessToken: string } | null,
@@ -840,9 +891,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
840
891
  const MAX_RETRIES = 1;
841
892
  const MAX_RATE_LIMIT_RETRIES = 3;
842
893
  const RATE_LIMIT_BACKOFF_MS = [15_000, 30_000, 60_000]; // 15s, 30s, 60s
894
+ // Allow 2 refresh attempts per turn (token can rotate mid-conversation).
895
+ // Counter resets on successful turn (see result handler) so next turn gets a fresh budget.
896
+ const MAX_AUTH_RETRIES = 2;
843
897
  let retryCount = 0;
844
898
  let rateLimitRetryCount = 0;
845
- let authRetried = false;
899
+ let authRetryCount = 0;
846
900
  let hadAnyEvents = false;
847
901
  retryLoop: while (true) {
848
902
  // Reset streaming state on retry — clears stale content from failed attempts
@@ -919,59 +973,36 @@ export class ClaudeAgentSdkProvider implements AIProvider {
919
973
  // Intercept SDK's internal api_retry with 401 — the SDK will retry up to 10 times
920
974
  // with exponential backoff using the same expired token, wasting 2+ minutes.
921
975
  // Instead, refresh the OAuth token immediately and restart the query.
922
- if (subtype === "api_retry" && (msg as any).error_status === 401 && account && !authRetried) {
923
- authRetried = true;
924
- try {
925
- // force=true: token got 401, so it's invalid regardless of expiresAt
926
- await accountService.refreshAccessToken(account.id, false, true);
927
- const refreshedAccount = accountService.getWithTokens(account.id);
928
- if (refreshedAccount) {
929
- const label = refreshedAccount.label ?? refreshedAccount.email ?? "Unknown";
930
- console.log(`[sdk] session=${sessionId} intercepted api_retry 401 — refreshing token for ${account.id} (${label})`);
931
- yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
932
- const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
933
- closeCurrentStream();
934
- const { generator: earlyAuthGen, controller: earlyAuthCtrl } = createMessageChannel();
935
- const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
936
- if (!hasHistory) earlyAuthCtrl.push(firstMsg);
937
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: retryEnv };
938
- const rq = query({
939
- prompt: earlyAuthGen,
940
- options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
941
- });
942
- this.streamingSessions.set(sessionId, { meta, query: rq, controller: earlyAuthCtrl });
943
- this.activeQueries.set(sessionId, rq);
944
- eventSource = rq;
945
- continue retryLoop;
946
- }
947
- } catch (refreshErr) {
948
- console.error(`[sdk] session=${sessionId} early OAuth refresh failed:`, refreshErr);
949
- accountSelector.onAuthError(account.id);
950
- // Refresh failed (e.g. temporary account with no refresh token).
951
- // Abort the current query immediately and try switching to a different account.
952
- const nextAcc = accountSelector.next();
953
- if (nextAcc && nextAcc.id !== account.id) {
954
- account = nextAcc;
955
- const label = nextAcc.label ?? nextAcc.email ?? "Unknown";
956
- console.log(`[sdk] session=${sessionId} refresh failed — switching to ${nextAcc.id} (${label})`);
957
- yield { type: "account_retry" as const, reason: "Switching account", accountId: nextAcc.id, accountLabel: label };
958
- const switchEnv = this.buildQueryEnv(meta.projectPath, nextAcc);
959
- closeCurrentStream();
960
- const { generator: switchGen, controller: switchCtrl } = createMessageChannel();
961
- const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
962
- if (!hasHistory) switchCtrl.push(firstMsg);
963
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: switchEnv };
964
- const rq = query({
965
- prompt: switchGen,
966
- options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
967
- });
968
- this.streamingSessions.set(sessionId, { meta, query: rq, controller: switchCtrl });
969
- this.activeQueries.set(sessionId, rq);
970
- eventSource = rq;
971
- continue retryLoop;
972
- }
973
- // No other account available — let SDK continue and eventually emit error
976
+ if (subtype === "api_retry" && (msg as any).error_status === 401 && account) {
977
+ const recovered = yield* this.recoverFromAuthError({
978
+ sessionId,
979
+ account,
980
+ authRetryCount,
981
+ maxRetries: MAX_AUTH_RETRIES,
982
+ context: "api_retry",
983
+ });
984
+ if (recovered) {
985
+ authRetryCount = recovered.newRetryCount;
986
+ account = recovered.account;
987
+ const retryEnv = this.buildQueryEnv(meta.projectPath, account);
988
+ closeCurrentStream();
989
+ const { generator: earlyAuthGen, controller: earlyAuthCtrl } = createMessageChannel();
990
+ const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
991
+ if (!hasHistory) earlyAuthCtrl.push(firstMsg);
992
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: retryEnv };
993
+ const rq = query({
994
+ prompt: earlyAuthGen,
995
+ options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
996
+ });
997
+ this.streamingSessions.set(sessionId, { meta, query: rq, controller: earlyAuthCtrl });
998
+ this.activeQueries.set(sessionId, rq);
999
+ eventSource = rq;
1000
+ continue retryLoop;
974
1001
  }
1002
+ // No recovery possible — break immediately to avoid SDK internal 10x retry hang
1003
+ console.warn(`[sdk] session=${sessionId} api_retry 401 with no recovery — tearing down streaming session`);
1004
+ yield { type: "error", message: "API authentication failed. Check your account credentials in Settings → Accounts." };
1005
+ break;
975
1006
  }
976
1007
 
977
1008
  // Yield system events so streaming loop can transition phases
@@ -1097,47 +1128,35 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1097
1128
  console.error(`[sdk] session=${sessionId} cwd=${effectiveCwd} assistant error: ${assistantError} (isFirst=${isFirstMessage} retry=${retryCount})`);
1098
1129
  console.error(`[sdk] assistant message dump: ${JSON.stringify(msg).slice(0, 2000)}`);
1099
1130
 
1100
- // OAuth token expired — refresh and retry once before showing error
1101
- if (assistantError === "authentication_failed" && account && !authRetried) {
1102
- authRetried = true;
1103
- try {
1104
- // force=true: token got 401, so it's invalid regardless of expiresAt
1105
- await accountService.refreshAccessToken(account.id, false, true);
1106
- const refreshedAccount = accountService.getWithTokens(account.id);
1107
- if (refreshedAccount) {
1108
- const label = refreshedAccount.label ?? refreshedAccount.email ?? "Unknown";
1109
- console.log(`[sdk] session=${sessionId} OAuth token refreshed for ${account.id} (${label}) — retrying`);
1110
- yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
1111
- const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
1112
- // Close failed query and old channel, create new channel + query with refreshed token.
1113
- closeCurrentStream();
1114
- const { generator: authRetryGen, controller: authRetryCtrl } = createMessageChannel();
1115
- const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
1116
- if (!hasHistory) authRetryCtrl.push(firstMsg);
1117
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: retryEnv };
1118
- const rq = query({
1119
- prompt: authRetryGen,
1120
- options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
1121
- });
1122
- this.streamingSessions.set(sessionId, { meta, query: rq, controller: authRetryCtrl });
1123
- this.activeQueries.set(sessionId, rq);
1124
- eventSource = rq;
1125
- continue retryLoop;
1126
- }
1127
- } catch (refreshErr) {
1128
- console.error(`[sdk] session=${sessionId} OAuth refresh failed:`, refreshErr);
1129
- accountSelector.onAuthError(account.id);
1131
+ // OAuth token expired — refresh (and/or switch account) and retry
1132
+ if (assistantError === "authentication_failed" && account) {
1133
+ const recovered = yield* this.recoverFromAuthError({
1134
+ sessionId,
1135
+ account,
1136
+ authRetryCount,
1137
+ maxRetries: MAX_AUTH_RETRIES,
1138
+ context: "assistant",
1139
+ });
1140
+ if (recovered) {
1141
+ authRetryCount = recovered.newRetryCount;
1142
+ account = recovered.account;
1143
+ const retryEnv = this.buildQueryEnv(meta.projectPath, account);
1144
+ closeCurrentStream();
1145
+ const { generator: authRetryGen, controller: authRetryCtrl } = createMessageChannel();
1146
+ const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
1147
+ if (!hasHistory) authRetryCtrl.push(firstMsg);
1148
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: retryEnv };
1149
+ const rq = query({
1150
+ prompt: authRetryGen,
1151
+ options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
1152
+ });
1153
+ this.streamingSessions.set(sessionId, { meta, query: rq, controller: authRetryCtrl });
1154
+ this.activeQueries.set(sessionId, rq);
1155
+ eventSource = rq;
1156
+ continue retryLoop;
1130
1157
  }
1131
- }
1132
-
1133
- // Auth failed permanently after retry — cooldown account and break loop.
1134
- // SDK doesn't send a result event after auth errors in streaming mode,
1135
- // so the streaming session would stay alive with broken credentials forever.
1136
- // Breaking here lets the finally block tear down the session, so the next
1137
- // user message creates a fresh session with a different account.
1138
- if (assistantError === "authentication_failed" && account && authRetried) {
1139
- accountSelector.onAuthError(account.id);
1140
- console.warn(`[sdk] session=${sessionId} auth permanently failed — tearing down streaming session`);
1158
+ // All recovery exhausted — tear down streaming session
1159
+ console.warn(`[sdk] session=${sessionId} auth permanently failed after ${authRetryCount} attempts — tearing down streaming session`);
1141
1160
  yield { type: "error", message: "API authentication failed. Check your account credentials in Settings → Accounts." };
1142
1161
  break;
1143
1162
  }
@@ -1275,38 +1294,33 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1275
1294
  yield { type: "error", message: `Rate limited. Retried ${MAX_RATE_LIMIT_RETRIES} times without success.` };
1276
1295
  continue;
1277
1296
  } else if (errCode === 401) {
1278
- // Refresh token and retry — resume existing SDK session to preserve context
1279
- if (!authRetried) {
1280
- authRetried = true;
1281
- try {
1282
- // force=true: token got 401, so it's invalid regardless of expiresAt
1283
- await accountService.refreshAccessToken(account.id, false, true);
1284
- const refreshedAccount = accountService.getWithTokens(account.id);
1285
- if (refreshedAccount) {
1286
- const label = refreshedAccount.label ?? refreshedAccount.email ?? "Unknown";
1287
- console.log(`[sdk] 401 in result on account ${account.id} (${label}) — token refreshed, retrying`);
1288
- yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
1289
- closeCurrentStream();
1290
- const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
1291
- const { generator: authRetryGen2, controller: authRetryCtrl2 } = createMessageChannel();
1292
- const authHasHistory2 = (this.messageCount.get(sessionId) ?? 0) > 0;
1293
- if (!authHasHistory2) authRetryCtrl2.push(firstMsg);
1294
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: authHasHistory2 ? sessionId : undefined, env: retryEnv };
1295
- const rq = query({
1296
- prompt: authRetryGen2,
1297
- options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
1298
- });
1299
- this.streamingSessions.set(sessionId, { meta, query: rq, controller: authRetryCtrl2 });
1300
- this.activeQueries.set(sessionId, rq);
1301
- eventSource = rq;
1302
- continue retryLoop;
1303
- }
1304
- } catch {
1305
- accountSelector.onAuthError(account.id);
1306
- }
1307
- } else {
1308
- accountSelector.onAuthError(account.id);
1297
+ // Refresh (or switch account) and retry — resume existing SDK session to preserve context
1298
+ const recovered = yield* this.recoverFromAuthError({
1299
+ sessionId,
1300
+ account,
1301
+ authRetryCount,
1302
+ maxRetries: MAX_AUTH_RETRIES,
1303
+ context: "result",
1304
+ });
1305
+ if (recovered) {
1306
+ authRetryCount = recovered.newRetryCount;
1307
+ account = recovered.account;
1308
+ closeCurrentStream();
1309
+ const retryEnv = this.buildQueryEnv(meta.projectPath, account);
1310
+ const { generator: authRetryGen2, controller: authRetryCtrl2 } = createMessageChannel();
1311
+ const authHasHistory2 = (this.messageCount.get(sessionId) ?? 0) > 0;
1312
+ if (!authHasHistory2) authRetryCtrl2.push(firstMsg);
1313
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: authHasHistory2 ? sessionId : undefined, env: retryEnv };
1314
+ const rq = query({
1315
+ prompt: authRetryGen2,
1316
+ options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
1317
+ });
1318
+ this.streamingSessions.set(sessionId, { meta, query: rq, controller: authRetryCtrl2 });
1319
+ this.activeQueries.set(sessionId, rq);
1320
+ eventSource = rq;
1321
+ continue retryLoop;
1309
1322
  }
1323
+ // All recovery exhausted — fall through to normal result error surfacing
1310
1324
  } else {
1311
1325
  // Only mark success when the result is actually successful,
1312
1326
  // not for unrecognized error subtypes (e.g. quota exhaustion)
@@ -1443,6 +1457,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1443
1457
  resultContextWindowPct = undefined;
1444
1458
  lastAssistantUuid = undefined;
1445
1459
  sdkEventCount = 0;
1460
+ // Reset auth retry budget on successful turn — each new turn gets a fresh
1461
+ // budget so OAuth tokens rotated mid-conversation can still trigger refresh
1462
+ if (!subtype || subtype === "success") authRetryCount = 0;
1446
1463
  continue; // Wait for next turn from generator
1447
1464
  }
1448
1465
  }
@@ -219,14 +219,13 @@ async function waitForServerReady(statusFile: string, port: number) {
219
219
  export async function startServer(options: {
220
220
  port?: string;
221
221
  share?: boolean;
222
- config?: string;
223
222
  profile?: string;
224
223
  }) {
225
224
  // Tunnel always enabled — cloudflared shares the server publicly
226
225
  options.share = true;
227
226
 
228
227
  // Load config
229
- configService.load(options.config);
228
+ configService.load();
230
229
  const port = parseInt(options.port ?? String(configService.get("port")), 10);
231
230
  const host = configService.get("host");
232
231
 
@@ -368,11 +367,11 @@ export async function startServer(options: {
368
367
  if (process.platform === "linux") {
369
368
  // Update service file in case config changed (port, share, etc.)
370
369
  const { enableAutoStart } = await import("../services/autostart-register.ts");
371
- await enableAutoStart({ port, host, share: !!options.share, configPath: options.config, profile: options.profile });
370
+ await enableAutoStart({ port, host, share: !!options.share, profile: options.profile });
372
371
  startedViaService = true;
373
372
  } else if (process.platform === "darwin") {
374
373
  const { enableAutoStart } = await import("../services/autostart-register.ts");
375
- await enableAutoStart({ port, host, share: !!options.share, configPath: options.config, profile: options.profile });
374
+ await enableAutoStart({ port, host, share: !!options.share, profile: options.profile });
376
375
  startedViaService = true;
377
376
  }
378
377
  }
@@ -392,7 +391,7 @@ export async function startServer(options: {
392
391
  } else if (process.platform === "win32") {
393
392
  const superviseArgs = [
394
393
  "__supervise__", String(port), host,
395
- options.config ?? "", options.profile ?? "",
394
+ options.profile ?? "",
396
395
  ];
397
396
  if (options.share) superviseArgs.push("--share");
398
397
  while (superviseArgs.length > 1 && superviseArgs[superviseArgs.length - 1] === "") superviseArgs.pop();
@@ -423,7 +422,7 @@ export async function startServer(options: {
423
422
  } else {
424
423
  const superviseArgs = [
425
424
  "__supervise__", String(port), host,
426
- options.config ?? "", options.profile ?? "",
425
+ options.profile ?? "",
427
426
  ];
428
427
  if (options.share) superviseArgs.push("--share");
429
428
  while (superviseArgs.length > 1 && superviseArgs[superviseArgs.length - 1] === "") superviseArgs.pop();
@@ -508,7 +507,6 @@ export async function startServer(options: {
508
507
  const autoConfig = {
509
508
  port, host,
510
509
  share: !!options.share,
511
- configPath: options.config,
512
510
  profile: options.profile,
513
511
  };
514
512
  // skipStart: supervisor is already running from direct spawn above
@@ -532,18 +530,15 @@ if (process.argv.includes("__serve__")) {
532
530
  const idx = process.argv.indexOf("__serve__");
533
531
  const port = parseInt(process.argv[idx + 1] ?? "8080", 10);
534
532
  const host = process.argv[idx + 2] ?? "0.0.0.0";
535
- const configPath = process.argv[idx + 3] && process.argv[idx + 3] !== "_" ? process.argv[idx + 3] : undefined;
536
- const profileArg = process.argv[idx + 4] && process.argv[idx + 4] !== "_" ? process.argv[idx + 4] : undefined;
533
+ const profileArg = process.argv[idx + 3] && process.argv[idx + 3] !== "_" ? process.argv[idx + 3] : undefined;
537
534
 
538
- // Set DB profile for daemon child — explicit --profile takes priority over config-path detection
535
+ // Set DB profile for daemon child
539
536
  const { setDbProfile } = await import("../services/db.service.ts");
540
537
  if (profileArg) {
541
538
  setDbProfile(profileArg);
542
- } else if (configPath && /dev/i.test(configPath)) {
543
- setDbProfile("dev");
544
539
  }
545
540
 
546
- configService.load(configPath);
541
+ configService.load();
547
542
  await setupLogFile();
548
543
 
549
544
  // Sync externally-started tunnel URL + PID into tunnelService
@@ -5,7 +5,6 @@ export interface AutoStartConfig {
5
5
  port: number;
6
6
  host: string;
7
7
  share: boolean;
8
- configPath?: string;
9
8
  profile?: string;
10
9
  }
11
10
 
@@ -44,20 +43,16 @@ export function buildExecCommand(config: AutoStartConfig): string[] {
44
43
  if (isCompiledBinary()) {
45
44
  // Compiled binary: just run self with __supervise__ args
46
45
  const args = [process.execPath, "__supervise__", String(config.port), config.host];
47
- if (config.configPath) args.push(config.configPath);
48
46
  if (config.profile) args.push(config.profile);
49
47
  if (config.share) args.push("--share");
50
48
  return args;
51
49
  }
52
50
 
53
- // Bun runtime: bun run <script> __supervise__ <port> <host> [config] [profile]
51
+ // Bun runtime: bun run <script> __supervise__ <port> <host> [profile]
54
52
  const bunPath = resolveBunPath();
55
53
  const scriptPath = resolve(import.meta.dir, "supervisor.ts");
56
54
  const args = [bunPath, "run", scriptPath, "__supervise__", String(config.port), config.host];
57
- if (config.configPath) args.push(config.configPath);
58
- else args.push(""); // placeholder
59
55
  if (config.profile) args.push(config.profile);
60
- else args.push(""); // placeholder
61
56
  if (config.share) args.push("--share");
62
57
  return args;
63
58
  }
@@ -1,5 +1,3 @@
1
- import { existsSync, readFileSync, renameSync } from "node:fs";
2
- import { resolve } from "node:path";
3
1
  import { randomBytes } from "node:crypto";
4
2
  import type { PpmConfig, ProjectConfig } from "../types/config.ts";
5
3
  import { DEFAULT_CONFIG, sanitizeConfig } from "../types/config.ts";
@@ -15,7 +13,6 @@ import {
15
13
  getProjectSettingsJson,
16
14
  patchProjectSettingsJson,
17
15
  } from "./db.service.ts";
18
- import { getPpmDir } from "./ppm-dir.ts";
19
16
 
20
17
  /** Top-level config keys stored in the config table (not projects) */
21
18
  const CONFIG_TABLE_KEYS: (keyof PpmConfig)[] = [
@@ -32,20 +29,8 @@ export const FILE_CONFIG_KEYS = {
32
29
  class ConfigService {
33
30
  private config: PpmConfig = structuredClone(DEFAULT_CONFIG);
34
31
 
35
- /** Load config from DB. If explicitPath given, import that YAML first. */
36
- load(explicitPath?: string): PpmConfig {
37
- // Import explicit YAML if provided (e.g. `ppm start -c path`)
38
- if (explicitPath && existsSync(explicitPath)) {
39
- this.importFromYaml(explicitPath);
40
- }
41
-
42
- // Auto-migrate: if config.yaml exists but DB has no config rows
43
- // Skip migration when using in-memory DB (tests)
44
- if (!getDbFilePath().includes(":memory:")) {
45
- this.migrateYamlIfNeeded();
46
- }
47
-
48
- // Load from DB
32
+ /** Load config from SQLite. Creates defaults if DB is empty. */
33
+ load(): PpmConfig {
49
34
  const dbConfig = getAllConfig();
50
35
  const dbProjects = getProjects();
51
36
 
@@ -102,7 +87,7 @@ class ConfigService {
102
87
  return this.config;
103
88
  }
104
89
 
105
- /** Get the DB file path (replaces getConfigPath for YAML) */
90
+ /** Get the DB file path */
106
91
  getConfigPath(): string {
107
92
  return getDbFilePath();
108
93
  }
@@ -184,84 +169,6 @@ class ConfigService {
184
169
  stmt.run(p.path, p.name, p.color ?? null, i);
185
170
  }
186
171
  }
187
-
188
- private migrateYamlIfNeeded(): void {
189
- const yamlPaths = [
190
- resolve(getPpmDir(), "config.yaml"),
191
- resolve(getPpmDir(), "config.dev.yaml"),
192
- ];
193
- for (const yamlPath of yamlPaths) {
194
- if (!existsSync(yamlPath)) continue;
195
- const existing = getAllConfig();
196
- if (Object.keys(existing).length > 0) return;
197
- this.importFromYaml(yamlPath);
198
- try {
199
- renameSync(yamlPath, yamlPath + ".bak");
200
- console.log(`[config] Migrated ${yamlPath} → SQLite (backup: .bak)`);
201
- } catch {}
202
- }
203
- this.migrateSessionMapIfNeeded();
204
- this.migratePushSubsIfNeeded();
205
- }
206
-
207
- private importFromYaml(path: string): void {
208
- try {
209
- const yaml = require("js-yaml");
210
- const raw = readFileSync(path, "utf-8");
211
- const parsed = yaml.load(raw) as Partial<PpmConfig> | null;
212
- if (!parsed) return;
213
- const merged = { ...structuredClone(DEFAULT_CONFIG), ...parsed };
214
- for (const key of CONFIG_TABLE_KEYS) {
215
- const value = (merged as any)[key];
216
- if (value !== undefined) {
217
- setConfigValue(String(key), JSON.stringify(value));
218
- }
219
- }
220
- if (merged.projects?.length) {
221
- this.syncProjectsToDb(merged.projects);
222
- }
223
- } catch (err) {
224
- console.error(`[config] Error importing YAML ${path}:`, (err as Error).message);
225
- }
226
- }
227
-
228
- private migrateSessionMapIfNeeded(): void {
229
- const mapPath = resolve(getPpmDir(), "session-map.json");
230
- if (!existsSync(mapPath)) return;
231
- try {
232
- const { setSessionMetadata } = require("./db.service.ts");
233
- const map = JSON.parse(readFileSync(mapPath, "utf-8")) as Record<string, string>;
234
- for (const [_ppmId, sdkId] of Object.entries(map)) {
235
- // Use SDK ID as canonical session ID (ppmId is legacy)
236
- setSessionMetadata(sdkId);
237
- }
238
- renameSync(mapPath, mapPath + ".bak");
239
- console.log("[config] Migrated session-map.json → SQLite");
240
- } catch {}
241
- }
242
-
243
- private migratePushSubsIfNeeded(): void {
244
- const subsPath = resolve(getPpmDir(), "push-subscriptions.json");
245
- if (!existsSync(subsPath)) return;
246
- try {
247
- const { upsertPushSubscription } = require("./db.service.ts");
248
- const subs = JSON.parse(readFileSync(subsPath, "utf-8")) as Array<{
249
- endpoint: string;
250
- keys: { p256dh: string; auth: string };
251
- expirationTime?: number | null;
252
- }>;
253
- for (const sub of subs) {
254
- upsertPushSubscription(
255
- sub.endpoint,
256
- sub.keys.p256dh,
257
- sub.keys.auth,
258
- sub.expirationTime != null ? String(sub.expirationTime) : null,
259
- );
260
- }
261
- renameSync(subsPath, subsPath + ".bak");
262
- console.log("[config] Migrated push-subscriptions.json → SQLite");
263
- } catch {}
264
- }
265
172
  }
266
173
 
267
174
  /** Singleton config service */
@@ -13,7 +13,7 @@ ppm start
13
13
  Start the PPM server (background by default)
14
14
  -p, --port <port> — Port to listen on
15
15
  -s, --share — (deprecated) Tunnel is now always enabled
16
- -c, --config <path> — Path to config file (YAML import into DB)
16
+ --profile <name> — DB profile name (e.g. 'dev' ppm.dev.db)
17
17
 
18
18
  ppm stop
19
19
  Stop the PPM server (supervisor stays alive)
@@ -25,7 +25,6 @@ ppm down
25
25
 
26
26
  ppm restart
27
27
  Restart the server (keeps tunnel alive)
28
- -c, --config <path> — Path to config file
29
28
  --force — Force resume from paused state
30
29
 
31
30
  ppm status
@@ -35,7 +34,6 @@ ppm status
35
34
 
36
35
  ppm open
37
36
  Open PPM in browser
38
- -c, --config <path> — Path to config file
39
37
 
40
38
  ppm logs
41
39
  View PPM daemon logs
@@ -203,7 +201,6 @@ ppm autostart enable
203
201
  Register PPM to start automatically on boot
204
202
  -p, --port <port> — Override port
205
203
  -s, --share — (deprecated) Tunnel is now always enabled
206
- -c, --config <path> — Config file path
207
204
  --profile <name> — DB profile name
208
205
 
209
206
  ppm autostart disable
@@ -2,7 +2,7 @@
2
2
  * Supervisor process — long-lived parent that manages server child + tunnel child.
3
3
  * Respawns children on crash with exponential backoff.
4
4
  * Health-checks server (/api/health) and tunnel URL (public probe).
5
- * Entry: __supervise__ <port> <host> [config] [profile] [--share]
5
+ * Entry: __supervise__ <port> <host> [profile] [--share]
6
6
  */
7
7
  import type { Subprocess } from "bun";
8
8
  import { resolve } from "node:path";
@@ -782,7 +782,6 @@ export function shutdown() {
782
782
  export async function runSupervisor(opts: {
783
783
  port: number;
784
784
  host: string;
785
- config?: string;
786
785
  profile?: string;
787
786
  share: boolean;
788
787
  }) {
@@ -822,7 +821,7 @@ export async function runSupervisor(opts: {
822
821
  // Build __serve__ args
823
822
  const serverArgs = [
824
823
  "__serve__", String(opts.port), opts.host,
825
- opts.config ?? "", opts.profile ?? "",
824
+ opts.profile ?? "",
826
825
  ];
827
826
  // Strip trailing empty args
828
827
  while (serverArgs.length > 0 && serverArgs[serverArgs.length - 1] === "") serverArgs.pop();
@@ -950,8 +949,7 @@ if (process.argv.includes("__supervise__")) {
950
949
  const idx = process.argv.indexOf("__supervise__");
951
950
  const port = parseInt(process.argv[idx + 1] ?? "8080", 10);
952
951
  const host = process.argv[idx + 2] ?? "0.0.0.0";
953
- const config = process.argv[idx + 3] && process.argv[idx + 3] !== "_" ? process.argv[idx + 3] : undefined;
954
- const profile = process.argv[idx + 4] && process.argv[idx + 4] !== "_" ? process.argv[idx + 4] : undefined;
952
+ const profile = process.argv[idx + 3] && process.argv[idx + 3] !== "_" ? process.argv[idx + 3] : undefined;
955
953
  const share = process.argv.includes("--share");
956
954
 
957
955
  // Set DB profile for supervisor (needed to read config)
@@ -960,5 +958,5 @@ if (process.argv.includes("__supervise__")) {
960
958
  setDbProfile(profile);
961
959
  }
962
960
 
963
- runSupervisor({ port, host, config, profile, share });
961
+ runSupervisor({ port, host, profile, share });
964
962
  }