@narumitw/pi-sync 0.57.2 → 0.57.5

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.
Files changed (89) hide show
  1. package/README.md +5 -1
  2. package/dist/chunks/{agent-plan-K3CRYVXV.ts → agent-plan-3L7CRFDM.ts} +28 -11
  3. package/dist/chunks/agent-plan-3L7CRFDM.ts.map +7 -0
  4. package/dist/chunks/agent-refusal-retry-AH7TXOQT.ts +69 -0
  5. package/dist/chunks/agent-refusal-retry-AH7TXOQT.ts.map +7 -0
  6. package/dist/chunks/{agent-runner-E2JU2VZ6.ts → agent-runner-WMSCKSCB.ts} +7 -5
  7. package/dist/chunks/agent-runner-WMSCKSCB.ts.map +7 -0
  8. package/dist/chunks/{agent-store-A3F6IYWZ.ts → agent-store-55CZOCPX.ts} +25 -3
  9. package/dist/chunks/agent-store-55CZOCPX.ts.map +7 -0
  10. package/dist/chunks/{chunk-QGK445K7.ts → chunk-3ST7RSYN.ts} +1 -1
  11. package/dist/chunks/chunk-3ST7RSYN.ts.map +7 -0
  12. package/dist/chunks/{chunk-OWBAZA6J.ts → chunk-DT6TOCIG.ts} +2 -2
  13. package/dist/chunks/{chunk-JJTARUSO.ts → chunk-JCH3VXIO.ts} +3 -2
  14. package/dist/chunks/{chunk-JJTARUSO.ts.map → chunk-JCH3VXIO.ts.map} +2 -2
  15. package/dist/chunks/{chunk-OEWQVWLC.ts → chunk-ORGBI36T.ts} +4 -4
  16. package/dist/chunks/{chunk-EUMINX7L.ts → chunk-P7DD5PWZ.ts} +2 -2
  17. package/dist/chunks/{chunk-VJN7KU4J.ts → chunk-PG7NMFP2.ts} +83 -18
  18. package/dist/chunks/chunk-PG7NMFP2.ts.map +7 -0
  19. package/dist/chunks/{chunk-T3NC77TU.ts → chunk-R75IHAOJ.ts} +45 -18
  20. package/dist/chunks/chunk-R75IHAOJ.ts.map +7 -0
  21. package/dist/chunks/{chunk-4TQWFLOQ.ts → chunk-U3XRI2HF.ts} +28 -197
  22. package/dist/chunks/chunk-U3XRI2HF.ts.map +7 -0
  23. package/dist/chunks/chunk-WD6FJHCK.ts +140 -0
  24. package/dist/chunks/chunk-WD6FJHCK.ts.map +7 -0
  25. package/dist/chunks/{conflict-review-ZCGWFKEK.ts → conflict-review-T7GSOKNG.ts} +9 -8
  26. package/dist/chunks/{conflict-review-ZCGWFKEK.ts.map → conflict-review-T7GSOKNG.ts.map} +1 -1
  27. package/dist/chunks/{continuous-record-LFRVXQBD.ts → continuous-record-SQJ47CXN.ts} +3 -3
  28. package/dist/chunks/{continuous-sync-LAIMSW7D.ts → continuous-sync-U4HIKPQK.ts} +9 -9
  29. package/dist/chunks/{continuous-sync-LAIMSW7D.ts.map → continuous-sync-U4HIKPQK.ts.map} +2 -2
  30. package/dist/chunks/{git-backend-KYDZ673X.ts → git-backend-NYKSG5X6.ts} +4 -4
  31. package/dist/chunks/{manager-result-dispatcher-AGBBVDDS.ts → manager-result-dispatcher-HOCM2GDA.ts} +4 -4
  32. package/dist/chunks/{manager-ui-PSA2L6CE.ts → manager-ui-FXXO6TW4.ts} +54 -17
  33. package/dist/chunks/manager-ui-FXXO6TW4.ts.map +7 -0
  34. package/dist/chunks/{remote-selection-ui-5725JDNC.ts → remote-selection-ui-YFPGYGNE.ts} +3 -3
  35. package/dist/chunks/{s3-backend-Y7WSCLJI.ts → s3-backend-F7PR5FB3.ts} +4 -4
  36. package/dist/chunks/{setup-wizard-SOYOJQKP.ts → setup-wizard-OVHZYXNW.ts} +2 -2
  37. package/dist/chunks/{sync-inspection-XFJOAMJD.ts → sync-inspection-5MY2HI4P.ts} +3 -3
  38. package/dist/chunks/{sync-operations-W5HFFMTP.ts → sync-operations-GNPS6BLY.ts} +29 -17
  39. package/dist/chunks/sync-operations-GNPS6BLY.ts.map +7 -0
  40. package/dist/chunks/{webdav-backend-6K26F6LT.ts → webdav-backend-TGFUH3SF.ts} +4 -4
  41. package/dist/index.ts +28 -23
  42. package/dist/index.ts.map +2 -2
  43. package/docs/agent-continuous-hardening.md +79 -0
  44. package/docs/agent-mode-verification.md +2 -0
  45. package/docs/agent-quality-verification.md +41 -0
  46. package/docs/continuous-sync-verification.md +2 -0
  47. package/package.json +4 -4
  48. package/skills/configure-pi-sync/references/configuration.md +21 -5
  49. package/src/commands/command-execution.ts +7 -2
  50. package/src/commands/command-handler.ts +3 -1
  51. package/src/commands/command-types.ts +3 -1
  52. package/src/sync/agent-plan.ts +26 -9
  53. package/src/sync/agent-refusal-retry.ts +54 -0
  54. package/src/sync/agent-request.ts +3 -1
  55. package/src/sync/agent-runner.ts +8 -6
  56. package/src/sync/agent-store.ts +32 -1
  57. package/src/sync/agent-sync.ts +63 -13
  58. package/src/sync/continuous-lease.ts +2 -7
  59. package/src/sync/continuous-record.ts +95 -13
  60. package/src/sync/continuous-storage.ts +12 -3
  61. package/src/sync/continuous-sync.ts +2 -1
  62. package/src/sync/merged-sync.ts +37 -9
  63. package/src/sync/sync-mutations.ts +11 -0
  64. package/src/ui/cancellable-operation.ts +2 -0
  65. package/src/ui/manager-state.ts +1 -0
  66. package/src/ui/manager-ui.ts +59 -14
  67. package/src/ui/secret-input.ts +29 -211
  68. package/dist/chunks/agent-plan-K3CRYVXV.ts.map +0 -7
  69. package/dist/chunks/agent-runner-E2JU2VZ6.ts.map +0 -7
  70. package/dist/chunks/agent-store-A3F6IYWZ.ts.map +0 -7
  71. package/dist/chunks/chunk-4TQWFLOQ.ts.map +0 -7
  72. package/dist/chunks/chunk-QGK445K7.ts.map +0 -7
  73. package/dist/chunks/chunk-QOC47PBC.ts +0 -97
  74. package/dist/chunks/chunk-QOC47PBC.ts.map +0 -7
  75. package/dist/chunks/chunk-T3NC77TU.ts.map +0 -7
  76. package/dist/chunks/chunk-VJN7KU4J.ts.map +0 -7
  77. package/dist/chunks/manager-ui-PSA2L6CE.ts.map +0 -7
  78. package/dist/chunks/sync-operations-W5HFFMTP.ts.map +0 -7
  79. /package/dist/chunks/{chunk-OWBAZA6J.ts.map → chunk-DT6TOCIG.ts.map} +0 -0
  80. /package/dist/chunks/{chunk-OEWQVWLC.ts.map → chunk-ORGBI36T.ts.map} +0 -0
  81. /package/dist/chunks/{chunk-EUMINX7L.ts.map → chunk-P7DD5PWZ.ts.map} +0 -0
  82. /package/dist/chunks/{continuous-record-LFRVXQBD.ts.map → continuous-record-SQJ47CXN.ts.map} +0 -0
  83. /package/dist/chunks/{git-backend-KYDZ673X.ts.map → git-backend-NYKSG5X6.ts.map} +0 -0
  84. /package/dist/chunks/{manager-result-dispatcher-AGBBVDDS.ts.map → manager-result-dispatcher-HOCM2GDA.ts.map} +0 -0
  85. /package/dist/chunks/{remote-selection-ui-5725JDNC.ts.map → remote-selection-ui-YFPGYGNE.ts.map} +0 -0
  86. /package/dist/chunks/{s3-backend-Y7WSCLJI.ts.map → s3-backend-F7PR5FB3.ts.map} +0 -0
  87. /package/dist/chunks/{setup-wizard-SOYOJQKP.ts.map → setup-wizard-OVHZYXNW.ts.map} +0 -0
  88. /package/dist/chunks/{sync-inspection-XFJOAMJD.ts.map → sync-inspection-5MY2HI4P.ts.map} +0 -0
  89. /package/dist/chunks/{webdav-backend-6K26F6LT.ts.map → webdav-backend-TGFUH3SF.ts.map} +0 -0
@@ -3,7 +3,13 @@ import { withLock } from "../state/lock.js";
3
3
  import { withStateDirectoryAccess } from "../state/state-directory.js";
4
4
  import { ContinuousError, type ContinuousErrorKind } from "./continuous-errors.js";
5
5
  import { type ContinuousLease, continuousLeaseOwner } from "./continuous-lease.js";
6
- import { continuousBinding, readContinuousPrivate, writeContinuousPrivate } from "./continuous-storage.js";
6
+ import {
7
+ CONTINUOUS_RECORD_MAX_BYTES,
8
+ continuousBinding,
9
+ readContinuousPrivate,
10
+ readContinuousPrivateDocument,
11
+ writeContinuousPrivate,
12
+ } from "./continuous-storage.js";
7
13
 
8
14
  export const REQUEST_WINDOW_MS = 15 * 60_000;
9
15
  export interface ContinuousRecord {
@@ -93,10 +99,12 @@ export function validateContinuousRecord(value: unknown, binding: string): asser
93
99
  )
94
100
  throw new Error("Invalid continuous sync state; evidence retained.");
95
101
  }
102
+ function emptyContinuousRecord(binding: string): ContinuousRecord {
103
+ return { version: 1, binding, pending: true, phase: "waiting", next: 0, failures: 0, calls: [], attempts: {} };
104
+ }
96
105
  export async function readContinuousRecord(binding: string): Promise<ContinuousRecord> {
97
106
  const value = await readContinuousPrivate(`${binding}.json`);
98
- if (value === undefined)
99
- return { version: 1, binding, pending: true, phase: "waiting", next: 0, failures: 0, calls: [], attempts: {} };
107
+ if (value === undefined) return emptyContinuousRecord(binding);
100
108
  validateContinuousRecord(value, binding);
101
109
  return value;
102
110
  }
@@ -131,16 +139,20 @@ export async function reserveContinuousRequest(
131
139
  lease: ContinuousLease,
132
140
  key: string,
133
141
  clock: () => number = Date.now,
142
+ signal?: AbortSignal,
134
143
  ) {
144
+ signal?.throwIfAborted();
135
145
  if (!digest(key)) throw new Error("Invalid continuous candidate key.");
136
146
  let failure: ContinuousError | undefined;
137
147
  await updateContinuousRecord(binding, lease, (record) => {
148
+ signal?.throwIfAborted();
138
149
  const now = clock();
150
+ signal?.throwIfAborted();
139
151
  normalizeContinuousClock(record, now);
140
152
  if ((record.attempts[key] ?? 0) >= 2)
141
153
  failure = new ContinuousError(
142
154
  "model",
143
- "Automatic request limit reached for this evidence. Use Sync now for an explicit retry.",
155
+ "Automatic request limit reached for this evidence. Change the evidence or review /sync; Sync now does not reset this limit.",
144
156
  );
145
157
  else if (record.calls.length >= 2)
146
158
  failure = new ContinuousError(
@@ -153,27 +165,97 @@ export async function reserveContinuousRequest(
153
165
  record.calls.push({ key, at: now });
154
166
  }
155
167
  });
168
+ // Atomic publication is not interruptible. A reservation written before cancellation stays spent.
169
+ signal?.throwIfAborted();
156
170
  if (failure) throw failure;
157
171
  }
158
172
 
173
+ const recoveryActions: Record<ContinuousErrorKind, string> = {
174
+ transient: "Wait for the scheduled retry. Check network and service availability if failures continue.",
175
+ contention: "Wait for the active sync operation to finish. Do not remove a live lock.",
176
+ stale: "Wait for a fresh inspection after the retry delay. Local or remote evidence changed.",
177
+ authentication: "Repair storage or Pi model authentication, then reopen /sync. Do not share credentials.",
178
+ storage: "Repair private-state permissions, free space or storage access. Retain accounting and recovery evidence.",
179
+ unsafe:
180
+ "Remove secrets or repair unsupported conflicts. Changed evidence is inspected again; review /sync if needed.",
181
+ model:
182
+ "Review conflict evidence and Pi model access. Sync now reuses cached refusals; a refusal needs changed evidence or a separately confirmed retry.",
183
+ budget:
184
+ "Wait for the window allowance shown above. Waiting does not restore exhausted candidates or accounting capacity.",
185
+ unknown:
186
+ "Review /sync diagnostics and reconcile pending recovery before retrying. Do not delete private evidence or blindly republish.",
187
+ };
188
+
189
+ function serializedBytes(record: ContinuousRecord): number {
190
+ // Keep this aligned with writeJson's private-document encoding.
191
+ return Buffer.byteLength(`${JSON.stringify(record, null, "\t")}\n`);
192
+ }
193
+
194
+ /** Check space for one reservation without retaining or publishing the synthetic candidate. */
195
+ function hasReservationCapacity(record: ContinuousRecord, now: number): boolean {
196
+ const existing = Object.keys(record.attempts).find((key) => record.attempts[key] === 1);
197
+ let key = existing ?? "0".repeat(64);
198
+ if (!existing) {
199
+ for (let index = 1; record.attempts[key] !== undefined; index++) key = index.toString(16).padStart(64, "0");
200
+ }
201
+ return (
202
+ serializedBytes({
203
+ ...record,
204
+ calls: [...record.calls, { key, at: now }],
205
+ attempts: { ...record.attempts, [key]: (record.attempts[key] ?? 0) + 1 },
206
+ }) <= CONTINUOUS_RECORD_MAX_BYTES
207
+ );
208
+ }
209
+
159
210
  /** Read-only status: no directory creation, lease, mutation, request reservation or provider call. */
160
- export async function continuousStatusLines(config: AnySyncConfig): Promise<string[]> {
211
+ export async function continuousStatusLines(config: AnySyncConfig, clock: () => number = Date.now): Promise<string[]> {
161
212
  if (!config.continuous) return ["Continuous sync: Off"];
162
213
  if (!config.agentMode) return ["Continuous sync: paused; enable Agent mode first"];
163
214
  try {
164
- const record = await readContinuousRecord(await continuousBinding(config));
215
+ const binding = await continuousBinding(config);
216
+ const document = await readContinuousPrivateDocument(`${binding}.json`);
217
+ const record = document === undefined ? emptyContinuousRecord(binding) : document.value;
218
+ validateContinuousRecord(record, binding);
219
+ const bytes = serializedBytes(record);
220
+ // Only this detached read is normalized. A Status read never publishes clock repairs or expires durable calls.
221
+ const now = clock();
222
+ normalizeContinuousClock(record, now);
223
+ const spent = Object.values(record.attempts).filter((count) => count === 2).length;
224
+ const eligible =
225
+ record.calls.length < 2 ? now : Math.min(...record.calls.map((call) => call.at)) + REQUEST_WINDOW_MS;
226
+ const capacityRecord = { ...record, calls: [...record.calls] };
227
+ normalizeContinuousClock(capacityRecord, eligible);
228
+ const available = hasReservationCapacity(capacityRecord, eligible);
229
+ const owner = await continuousLeaseOwner(binding).catch(
230
+ () => "Local scheduler lease: private owner metadata unavailable; retain evidence and repair access",
231
+ );
165
232
  return [
166
- `Continuous sync: ${record.phase}${record.error ? ` (${record.error})` : ""}`,
167
- await continuousLeaseOwner(record.binding),
168
- `Last successful background check: ${record.checked ? new Date(record.checked).toISOString() : "not yet checked"}`,
169
- `Last successful background transfer: ${record.transferred ? new Date(record.transferred).toISOString() : "none"}`,
233
+ `Continuous sync: ${record.phase}${record.error ? ` (${record.error})` : ""}${document === undefined ? "; no accounting record yet" : ""}`,
234
+ owner,
235
+ "Scheduler owner model: unavailable; the observing session's selected model is not proof of the owner's model.",
236
+ `Automatic requests (rolling 15 minutes, window only): ${record.calls.length}/2 used; ${2 - record.calls.length} remaining.`,
237
+ `Next request eligibility: ${available ? (eligible === now ? "now (window allowance)" : `${new Date(eligible).toISOString()} (window allowance)`) : "unavailable (accounting full)"}. Candidate, capacity, ownership and safety checks still apply.`,
238
+ `Spent candidates: ${spent} exhausted / ${Object.keys(record.attempts).length} tracked; 2 automatic requests per candidate. Expired windows do not clear this limit.`,
239
+ `Accounting: ${bytes}/${CONTINUOUS_RECORD_MAX_BYTES} serialized bytes; ${Math.max(0, CONTINUOUS_RECORD_MAX_BYTES - bytes)} bytes free; ${document?.bytes ?? 0} bytes on disk${available ? "." : "; full for another reservation."}`,
240
+ ...(!available
241
+ ? [
242
+ "Accounting recovery: pause Continuous sync and seek reviewed recovery. Do not delete accounting, prune candidates or reset limits to regain requests.",
243
+ ]
244
+ : []),
245
+ `Last successful background check: ${record.checked !== undefined ? new Date(record.checked).toISOString() : "not yet checked"}`,
246
+ `Last successful background transfer: ${record.transferred !== undefined ? new Date(record.transferred).toISOString() : "none"}`,
170
247
  ...(record.phase === "retry" ? [`Next background attempt: ${new Date(record.next).toISOString()}`] : []),
171
- ...(record.phase === "blocked"
172
- ? ["Background sync needs changed evidence or repair. Review /sync, or use Sync now for an explicit retry."]
248
+ ...(record.error ? [`Recovery (${record.error}): ${recoveryActions[record.error]}`] : []),
249
+ ...(record.phase === "blocked" && !record.error
250
+ ? ["Review /sync diagnostics and repair changed evidence. Retain private recovery state."]
173
251
  : []),
174
252
  "Monitoring requires an eligible open Pi session. Status does not spend model requests or clear retry limits.",
175
253
  ];
176
254
  } catch {
177
- return ["Continuous sync: private state unavailable; retain evidence and repair access before retrying."];
255
+ return [
256
+ "Continuous sync: private state unavailable; usage, eligibility and accounting bytes cannot be verified.",
257
+ `Accounting capacity: ${CONTINUOUS_RECORD_MAX_BYTES} bytes; do not treat unavailable accounting as unused allowance.`,
258
+ "Recovery: pause Continuous sync, retain malformed, unknown or unsafe evidence, and repair private-state access. Do not delete accounting or reset limits.",
259
+ ];
178
260
  }
179
261
  }
@@ -8,6 +8,8 @@ import { writeJson } from "../state/json-file.js";
8
8
  import { stateDir } from "../state/state-directory.js";
9
9
  import { ensureStateDir } from "../state/sync-state-store.js";
10
10
 
11
+ export const CONTINUOUS_RECORD_MAX_BYTES = 131072;
12
+
11
13
  export function hashContinuous(value: unknown) {
12
14
  return createHash("sha256")
13
15
  .update(JSON.stringify(value) ?? "null")
@@ -30,6 +32,12 @@ export async function checkContinuousRoot() {
30
32
  throw new Error("Unsafe continuous sync directory; evidence retained.");
31
33
  }
32
34
  export async function readContinuousPrivate(name: string): Promise<unknown> {
35
+ return (await readContinuousPrivateDocument(name))?.value;
36
+ }
37
+ /** Read-only metadata; uses the same private-file checks as runtime reads. */
38
+ export async function readContinuousPrivateDocument(
39
+ name: string,
40
+ ): Promise<{ value: unknown; bytes: number } | undefined> {
33
41
  try {
34
42
  await checkContinuousRoot();
35
43
  const file = path.join(continuousRoot(), name);
@@ -38,11 +46,12 @@ export async function readContinuousPrivate(name: string): Promise<unknown> {
38
46
  !stat.isFile() ||
39
47
  stat.isSymbolicLink() ||
40
48
  stat.nlink !== 1 ||
41
- stat.size > 131072 ||
49
+ stat.size > CONTINUOUS_RECORD_MAX_BYTES ||
42
50
  (process.platform !== "win32" && stat.mode & 0o077)
43
51
  )
44
52
  throw new Error("Unsafe continuous sync record; evidence retained.");
45
- return JSON.parse(await fs.readFile(file, "utf8"));
53
+ const bytes = await fs.readFile(file);
54
+ return { value: JSON.parse(bytes.toString("utf8")), bytes: bytes.byteLength };
46
55
  } catch (error) {
47
56
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
48
57
  throw error;
@@ -51,5 +60,5 @@ export async function readContinuousPrivate(name: string): Promise<unknown> {
51
60
  export async function writeContinuousPrivate(name: string, value: unknown) {
52
61
  await checkContinuousRoot();
53
62
  await readContinuousPrivate(name); // Never replace unsafe or malformed evidence.
54
- await writeJson(path.join(continuousRoot(), name), value, { maxBytes: 131072 });
63
+ await writeJson(path.join(continuousRoot(), name), value, { maxBytes: CONTINUOUS_RECORD_MAX_BYTES });
55
64
  }
@@ -451,7 +451,8 @@ export function createContinuousSync(loaders: SyncLoaders, clock: ContinuousCloc
451
451
  signal,
452
452
  background: {
453
453
  validate,
454
- reserve: (key) => reserveContinuousRequest(binding, lease, key, () => clock.now()),
454
+ reserve: (key, requestSignal) =>
455
+ reserveContinuousRequest(binding, lease, key, () => clock.now(), requestSignal),
455
456
  },
456
457
  },
457
458
  (options) => operations.syncBoth(ctx, options),
@@ -48,6 +48,7 @@ import {
48
48
  import { type ConflictResolution, resolveReviewedGroup } from "./conflict-resolution.js";
49
49
  import { pruneCompletedConflicts } from "./conflict-retention.js";
50
50
  import { resolveContentConflicts } from "./content-conflicts.js";
51
+ import { ContinuousError } from "./continuous-errors.js";
51
52
  import { planFileMerge } from "./file-merge-planner.js";
52
53
  import { overlayLocalFields, portableSnapshot, sameLocalFields } from "./local-fields.js";
53
54
  import { applyMergedSnapshot, preflightMergedTargets } from "./merge-apply.js";
@@ -96,8 +97,16 @@ export async function mergeSync(
96
97
  return false;
97
98
  }
98
99
  };
100
+ const validateRetryAuthorization = (config: AnySyncConfig) => {
101
+ if (
102
+ options.agentRetryRefusal &&
103
+ (!config.agentMode || options.auto || options.background || !ctx.hasUI || !["tui", "rpc"].includes(ctx.mode))
104
+ )
105
+ throw new Error("Refused-candidate retry requires foreground Agent mode in TUI or RPC.");
106
+ };
99
107
  const config = await loadConfig(options.setup);
100
108
  validateOwner(config);
109
+ validateRetryAuthorization(config);
101
110
  const configToken = syncCheckConfigFingerprint(config);
102
111
  const modelToken = JSON.stringify(ctx.model);
103
112
  const sessionRoot = config.include.includes("sessions") ? await effectiveSessionRoot(ctx) : undefined;
@@ -112,6 +121,7 @@ export async function mergeSync(
112
121
  validateOwner();
113
122
  const latest = await loadConfig(options.setup);
114
123
  validateOwner(latest);
124
+ validateRetryAuthorization(latest);
115
125
  if (configToken !== syncCheckConfigFingerprint(latest))
116
126
  throw new Error("Sync setup or settings changed during transfer; reopen sync.");
117
127
  if (sessionRoot !== undefined) {
@@ -130,6 +140,8 @@ export async function mergeSync(
130
140
  await validate();
131
141
  const pending = await timings.measure("transfer journal read", () => readMergeJournal(config));
132
142
  await validate();
143
+ if (pending && options.agentRetryRefusal)
144
+ throw new Error("A pending merge journal requires ordinary Sync now before retrying a refused candidate.");
133
145
  if (pending) {
134
146
  const recovered = await timings.measure("transfer journal recovery", () =>
135
147
  completeJournal(
@@ -339,7 +351,8 @@ export async function mergeSync(
339
351
  );
340
352
  await validate();
341
353
  if (plan.kind !== "planned" || (plan.conflicts.length && !config.partialSync))
342
- throw new Error(
354
+ throw new ContinuousError(
355
+ "unsafe",
343
356
  "Agent mode paused: unsupported or refused conflict groups remain; no transfer was performed.",
344
357
  );
345
358
  }
@@ -456,7 +469,10 @@ export async function mergeSync(
456
469
  const beforeEvidence = await timings.measure("transfer remote head", () => backend.readHead(options.signal));
457
470
  await validate();
458
471
  if (!beforeEvidence || !backend.sameRevision(beforeEvidence.revision, head.revision))
459
- throw new Error("Remote changed before baseline acceptance; retry from a fresh observation.");
472
+ throw new ContinuousError(
473
+ "stale",
474
+ "Remote changed before baseline acceptance; retry from a fresh observation.",
475
+ );
460
476
  progress = await persistProgress();
461
477
  await validate();
462
478
  const currentLocal = await timings.measure("transfer local snapshot", () =>
@@ -468,14 +484,20 @@ export async function mergeSync(
468
484
  syncStateFingerprint(await timings.measure("transfer state read", () => readStateForConfig(config))) !==
469
485
  syncStateFingerprint(state)
470
486
  )
471
- throw new Error(
487
+ throw new ContinuousError(
488
+ "stale",
472
489
  "Local content or baseline changed before baseline acceptance; retry from a fresh observation.",
473
490
  );
474
491
  await validate();
475
492
  const currentHead = await timings.measure("transfer remote head", () => backend.readHead(options.signal));
476
493
  await validate();
477
494
  if (!currentHead || !backend.sameRevision(currentHead.revision, head.revision))
478
- throw new Error("Remote changed before baseline acceptance; retry from a fresh observation.");
495
+ throw new ContinuousError(
496
+ "stale",
497
+ "Remote changed before baseline acceptance; retry from a fresh observation.",
498
+ );
499
+ await options.agentAcceptance?.commitCandidate?.();
500
+ await validate();
479
501
  await timings.measure("transfer baseline write", () =>
480
502
  writeAcceptedState(config, head, accepted, validate, progress),
481
503
  );
@@ -512,7 +534,10 @@ export async function mergeSync(
512
534
  );
513
535
  await validate();
514
536
  if (!config.skipSecretScan && scanSnapshot(upload).length)
515
- throw new Error("Refusing to merge possible secrets. Review managed content before syncing.");
537
+ throw new ContinuousError(
538
+ "unsafe",
539
+ "Refusing to merge possible secrets. Review managed content before syncing.",
540
+ );
516
541
  if (!options.yes && !config.agentMode) {
517
542
  const summary = mergedSyncSummary({
518
543
  setupName: config.setupName,
@@ -545,17 +570,19 @@ export async function mergeSync(
545
570
  createSnapshot(config.snapshotIdentity, snapshotOptions),
546
571
  );
547
572
  if (!sameHashes(fileHashMap(refreshed), fileHashMap(localRaw)))
548
- throw new Error("Local content changed during review; no merged transfer was performed.");
573
+ throw new ContinuousError("stale", "Local content changed during review; no merged transfer was performed.");
549
574
  if (
550
575
  syncStateFingerprint(await timings.measure("transfer state read", () => readStateForConfig(config))) !==
551
576
  syncStateFingerprint(state)
552
577
  )
553
- throw new Error("Sync baseline changed during review; retry from a fresh observation.");
578
+ throw new ContinuousError("stale", "Sync baseline changed during review; retry from a fresh observation.");
554
579
  await validate();
555
580
  const refreshedHead = await timings.measure("transfer remote head", () => backend.readHead(options.signal));
556
581
  await validate();
557
582
  if (!refreshedHead || !backend.sameRevision(refreshedHead.revision, head.revision))
558
- throw new Error("Remote changed during review; no merged transfer was performed.");
583
+ throw new ContinuousError("stale", "Remote changed during review; no merged transfer was performed.");
584
+ await options.agentAcceptance?.commitCandidate?.();
585
+ await validate();
559
586
  progress = await persistProgress();
560
587
  await validate();
561
588
  const backup = await timings.measure("transfer backup", () =>
@@ -589,7 +616,8 @@ export async function mergeSync(
589
616
  if (!sameHashes(fileHashMap(atCommit), fileHashMap(localRaw)) || atCommitState !== journal.stateIdentity) {
590
617
  await validate();
591
618
  await clearMergeJournal(config);
592
- throw new Error(
619
+ throw new ContinuousError(
620
+ "stale",
593
621
  "Local content or baseline changed before publication; candidate retired without transfer. Review a fresh sync.",
594
622
  );
595
623
  }
@@ -431,6 +431,17 @@ export async function syncBoth(
431
431
  const config = await loadConfig(options.setup);
432
432
  throwIfAborted(options.signal);
433
433
  options.background?.validate(config);
434
+ if (options.agentRetryRefusal) {
435
+ if (!config.agentMode || options.auto || options.background || !ctx.hasUI)
436
+ throw new Error("Enable Agent mode and use the foreground manager to retry a cached refusal.");
437
+ const state = await readStateForConfig(config);
438
+ validateMutation();
439
+ const pending = await readMergeJournal(config);
440
+ validateMutation();
441
+ if (!config.include.length || !state.lastAppliedSnapshot || pending)
442
+ throw new Error("Retry requires an accepted baseline and no pending journal. Use Sync now or reviewed recovery.");
443
+ return mergeSync(ctx, options, factory);
444
+ }
434
445
  if (config.include.length > 0 && options.auto && startupTransferEnabled(config))
435
446
  return mergeSync(ctx, options, factory);
436
447
  if (!options.auto && config.include.length > 0 && (await readMergeJournal(config)))
@@ -17,6 +17,7 @@ export type RunRoute = (
17
17
  onCommit?: () => void,
18
18
  target?: string,
19
19
  onDialog?: (active: boolean) => void,
20
+ retryRefusal?: boolean,
20
21
  ) => Promise<RunRouteResult | undefined>;
21
22
 
22
23
  export type CancellableOperationResult = RunRouteResult | { kind: "closed" } | { kind: "cancelled" };
@@ -106,6 +107,7 @@ export async function runCancellableOperation(
106
107
  );
107
108
  return {
108
109
  render(width: number) {
110
+ if (width <= 0) return [];
109
111
  const safeWidth = Math.max(1, width);
110
112
  const lines = loader.render(safeWidth);
111
113
  const bottomBorder = lines.at(-1);
@@ -130,6 +130,7 @@ export async function describeManagerState(
130
130
  : "Unavailable";
131
131
  const canSwitch = (await countValidSyncSetups(configuredTargets, signal)) > 1;
132
132
  const mainActions: string[] = [...MAIN_MENU_ACTIONS];
133
+ if (config.agentMode && !noSyncedContent) mainActions.splice(1, 0, "Retry refused model result…");
133
134
  if (canSwitch) mainActions.push("Switch sync setup");
134
135
  if (syncState?.unresolved?.length) mainActions.unshift("Review unresolved conflicts");
135
136
  const ordinaryTitle = [
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { Component } from "@earendil-works/pi-tui";
2
3
  import { type ActionMenuItem, defineMenu, runMenu } from "@narumitw/pi-tui-kit";
3
4
  import { loadConfig, syncCheckConfigFingerprint } from "../settings/config.js";
4
5
  import { operationBlocksChanges } from "../state/operation-availability.js";
@@ -33,6 +34,7 @@ export async function showSyncManager(
33
34
  type Screen = "main";
34
35
  type Action =
35
36
  | "review-attention"
37
+ | "retry-refusal"
36
38
  | "sync"
37
39
  | "switch"
38
40
  | "settings"
@@ -128,6 +130,27 @@ export async function showSyncManager(
128
130
  const disposition = await dispatchManagerResult(ctx, result, "sync", runRoute, sessionSignal);
129
131
  return disposition.kind === "close" ? { kind: "close" } : { kind: "stay" };
130
132
  },
133
+ "retry-refusal": async ({ signal: actionSignal }) => {
134
+ const signal = sessionSignal ? AbortSignal.any([sessionSignal, actionSignal]) : actionSignal;
135
+ const validate = captureMutationOwner(ctx, signal);
136
+ validate();
137
+ const retryRoute: RunRoute = (route, operationSignal, onCommit, target, onDialog) =>
138
+ runRoute(route, operationSignal, onCommit, target, onDialog, true);
139
+ const result = await runCancellableOperation(ctx, "Checking cached refusal…", "sync", retryRoute, {
140
+ commitAware: true,
141
+ cancelAcrossDialogs: true,
142
+ signal,
143
+ cancelledMessage: "Model retry cancelled. Submitted requests and committed writes cannot be undone.",
144
+ });
145
+ if (signal.aborted) return { kind: "close" };
146
+ validate();
147
+ if (result.kind === "decision-required" || result.kind === "remote-selection-required")
148
+ ctx.ui.notify(
149
+ "Model retry needs an established source and matching synced content. Use Sync now to review changed selection or recovery before retrying.",
150
+ "warning",
151
+ );
152
+ return { kind: result.kind === "closed" ? "close" : "stay" };
153
+ },
131
154
  switch: async () => {
132
155
  const result = await showSetupSwitcher(ctx, runRoute, undefined, sessionSignal);
133
156
  return result === "pull-attempted" || result === "closed" ? { kind: "close" } : { kind: "stay" };
@@ -231,7 +254,25 @@ export async function showSyncManager(
231
254
  },
232
255
  },
233
256
  });
234
- await runMenu(ctx, menu, {
257
+ const menuContext: ExtensionCommandContext = {
258
+ ...ctx,
259
+ ui: {
260
+ ...ctx.ui,
261
+ custom: (factory, customOptions) =>
262
+ ctx.ui.custom((...args) => {
263
+ const bound = (component: Component & { dispose?(): void }) => {
264
+ const render = component.render.bind(component);
265
+ // Kit menus use a one-cell layout floor; suppress it at a zero-column display boundary.
266
+ component.render = (width) => (width <= 0 ? [] : render(width));
267
+ return component;
268
+ };
269
+ const component = factory(...args);
270
+ // Keep synchronous factories synchronous, including adapters that inspect their returned component.
271
+ return component instanceof Promise ? component.then(bound) : bound(component);
272
+ }, customOptions),
273
+ },
274
+ };
275
+ await runMenu(menuContext, menu, {
235
276
  getState: async () => {
236
277
  const pendingAttention = options.getAttention?.();
237
278
  const observation = options.getObservation?.();
@@ -261,6 +302,7 @@ function syncMainMenuItem(
261
302
  ): ActionMenuItem<
262
303
  "main",
263
304
  | "sync"
305
+ | "retry-refusal"
264
306
  | "pull"
265
307
  | "push"
266
308
  | "diff"
@@ -279,6 +321,7 @@ function syncMainMenuItem(
279
321
  > {
280
322
  const actions = {
281
323
  "Sync now": "sync",
324
+ "Retry refused model result…": "retry-refusal",
282
325
  "Pull from remote…": "pull",
283
326
  "Push to remote…": "push",
284
327
  "View differences": "diff",
@@ -303,18 +346,20 @@ function syncMainMenuItem(
303
346
  label,
304
347
  action,
305
348
  description:
306
- action === "doctor"
307
- ? "Manage: review backend checks before running."
308
- : action === "settings" || action === "history" || action === "switch"
309
- ? "Manage"
310
- : action === "sync"
311
- ? "Transfer: choose a safe direction or review conflicts."
312
- : action === "pull"
313
- ? "Transfer: remote → local; review writes and removals."
314
- : action === "push"
315
- ? "Transfer: local → remote active snapshot; review publication."
316
- : action === "status" || action === "diff"
317
- ? "Inspect: read current local and remote state without transferring files."
318
- : undefined,
349
+ action === "retry-refusal"
350
+ ? "Transfer: confirm one fresh model request for a current cached refusal; may incur costs."
351
+ : action === "doctor"
352
+ ? "Manage: review backend checks before running."
353
+ : action === "settings" || action === "history" || action === "switch"
354
+ ? "Manage"
355
+ : action === "sync"
356
+ ? "Transfer: choose a safe direction or review conflicts."
357
+ : action === "pull"
358
+ ? "Transfer: remote → local; review writes and removals."
359
+ : action === "push"
360
+ ? "Transfer: local → remote active snapshot; review publication."
361
+ : action === "status" || action === "diff"
362
+ ? "Inspect: read current local and remote state without transferring files."
363
+ : undefined,
319
364
  };
320
365
  }