@astralyn/pi 0.82.3 → 0.82.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.
- package/CHANGELOG.md +49 -0
- package/dist/cli/config-selector.d.ts.map +1 -1
- package/dist/cli/config-selector.js +3 -2
- package/dist/cli/config-selector.js.map +1 -1
- package/dist/cli/startup-ui.d.ts.map +1 -1
- package/dist/cli/startup-ui.js +3 -2
- package/dist/cli/startup-ui.js.map +1 -1
- package/dist/core/agent-session.d.ts +2 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +33 -14
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/extensions/index.d.ts +2 -2
- package/dist/core/extensions/index.d.ts.map +1 -1
- package/dist/core/extensions/index.js +1 -1
- package/dist/core/extensions/index.js.map +1 -1
- package/dist/core/extensions/loader.d.ts +1 -1
- package/dist/core/extensions/loader.d.ts.map +1 -1
- package/dist/core/extensions/loader.js +2 -3
- package/dist/core/extensions/loader.js.map +1 -1
- package/dist/core/extensions/types.d.ts +18 -0
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js +11 -0
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/extensions/index.d.ts.map +1 -1
- package/dist/extensions/index.js +2 -0
- package/dist/extensions/index.js.map +1 -1
- package/dist/extensions/plan/constants.d.ts +31 -0
- package/dist/extensions/plan/constants.d.ts.map +1 -0
- package/dist/extensions/plan/constants.js +57 -0
- package/dist/extensions/plan/constants.js.map +1 -0
- package/dist/extensions/plan/index.d.ts +21 -0
- package/dist/extensions/plan/index.d.ts.map +1 -0
- package/dist/extensions/plan/index.js +291 -0
- package/dist/extensions/plan/index.js.map +1 -0
- package/dist/extensions/plan/schema.d.ts +15 -0
- package/dist/extensions/plan/schema.d.ts.map +1 -0
- package/dist/extensions/plan/schema.js +13 -0
- package/dist/extensions/plan/schema.js.map +1 -0
- package/dist/extensions/plan/state.d.ts +29 -0
- package/dist/extensions/plan/state.d.ts.map +1 -0
- package/dist/extensions/plan/state.js +74 -0
- package/dist/extensions/plan/state.js.map +1 -0
- package/dist/extensions/plan/storage.d.ts +18 -0
- package/dist/extensions/plan/storage.d.ts.map +1 -0
- package/dist/extensions/plan/storage.js +58 -0
- package/dist/extensions/plan/storage.js.map +1 -0
- package/dist/extensions/question/dialog.d.ts +3 -1
- package/dist/extensions/question/dialog.d.ts.map +1 -1
- package/dist/extensions/question/dialog.js +106 -52
- package/dist/extensions/question/dialog.js.map +1 -1
- package/dist/extensions/question/index.d.ts.map +1 -1
- package/dist/extensions/question/index.js +2 -2
- package/dist/extensions/question/index.js.map +1 -1
- package/dist/extensions/question/render-cache.d.ts +9 -0
- package/dist/extensions/question/render-cache.d.ts.map +1 -1
- package/dist/extensions/question/render-cache.js +22 -0
- package/dist/extensions/question/render-cache.js.map +1 -1
- package/dist/extensions/question/results.d.ts.map +1 -1
- package/dist/extensions/question/results.js +6 -1
- package/dist/extensions/question/results.js.map +1 -1
- package/dist/extensions/question/schema.d.ts +4 -0
- package/dist/extensions/question/schema.d.ts.map +1 -1
- package/dist/extensions/question/schema.js +19 -28
- package/dist/extensions/question/schema.js.map +1 -1
- package/dist/extensions/question/types.d.ts +1 -1
- package/dist/extensions/question/types.d.ts.map +1 -1
- package/dist/extensions/question/types.js.map +1 -1
- package/dist/extensions/rewind/anchor.d.ts +29 -0
- package/dist/extensions/rewind/anchor.d.ts.map +1 -0
- package/dist/extensions/rewind/anchor.js +35 -0
- package/dist/extensions/rewind/anchor.js.map +1 -0
- package/dist/extensions/rewind/engine.d.ts +2 -6
- package/dist/extensions/rewind/engine.d.ts.map +1 -1
- package/dist/extensions/rewind/engine.js +41 -10
- package/dist/extensions/rewind/engine.js.map +1 -1
- package/dist/extensions/rewind/index.d.ts.map +1 -1
- package/dist/extensions/rewind/index.js +55 -21
- package/dist/extensions/rewind/index.js.map +1 -1
- package/dist/extensions/rewind/menu.d.ts.map +1 -1
- package/dist/extensions/rewind/menu.js +7 -2
- package/dist/extensions/rewind/menu.js.map +1 -1
- package/dist/extensions/rewind/restore.d.ts +26 -9
- package/dist/extensions/rewind/restore.d.ts.map +1 -1
- package/dist/extensions/rewind/restore.js +58 -16
- package/dist/extensions/rewind/restore.js.map +1 -1
- package/dist/extensions/subagent/activity.d.ts +1 -0
- package/dist/extensions/subagent/activity.d.ts.map +1 -1
- package/dist/extensions/subagent/activity.js +20 -4
- package/dist/extensions/subagent/activity.js.map +1 -1
- package/dist/extensions/subagent/agents.d.ts.map +1 -1
- package/dist/extensions/subagent/agents.js +13 -18
- package/dist/extensions/subagent/agents.js.map +1 -1
- package/dist/extensions/subagent/constants.d.ts +5 -4
- package/dist/extensions/subagent/constants.d.ts.map +1 -1
- package/dist/extensions/subagent/constants.js +12 -5
- package/dist/extensions/subagent/constants.js.map +1 -1
- package/dist/extensions/subagent/index.d.ts.map +1 -1
- package/dist/extensions/subagent/index.js +12 -5
- package/dist/extensions/subagent/index.js.map +1 -1
- package/dist/extensions/subagent/render.d.ts.map +1 -1
- package/dist/extensions/subagent/render.js +166 -41
- package/dist/extensions/subagent/render.js.map +1 -1
- package/dist/extensions/subagent/resolve.d.ts +2 -5
- package/dist/extensions/subagent/resolve.d.ts.map +1 -1
- package/dist/extensions/subagent/resolve.js +8 -13
- package/dist/extensions/subagent/resolve.js.map +1 -1
- package/dist/extensions/subagent/runner.d.ts +3 -1
- package/dist/extensions/subagent/runner.d.ts.map +1 -1
- package/dist/extensions/subagent/runner.js +115 -66
- package/dist/extensions/subagent/runner.js.map +1 -1
- package/dist/extensions/subagent/schema.d.ts +0 -6
- package/dist/extensions/subagent/schema.d.ts.map +1 -1
- package/dist/extensions/subagent/schema.js +3 -4
- package/dist/extensions/subagent/schema.js.map +1 -1
- package/dist/extensions/subagent/sdk-runner.d.ts.map +1 -1
- package/dist/extensions/subagent/sdk-runner.js +37 -16
- package/dist/extensions/subagent/sdk-runner.js.map +1 -1
- package/dist/extensions/subagent/types.d.ts +10 -2
- package/dist/extensions/subagent/types.d.ts.map +1 -1
- package/dist/extensions/subagent/types.js.map +1 -1
- package/dist/extensions/todo/constants.d.ts +1 -1
- package/dist/extensions/todo/constants.d.ts.map +1 -1
- package/dist/extensions/todo/constants.js +29 -4
- package/dist/extensions/todo/constants.js.map +1 -1
- package/dist/extensions/todo/index.d.ts +1 -1
- package/dist/extensions/todo/index.d.ts.map +1 -1
- package/dist/extensions/todo/index.js +22 -3
- package/dist/extensions/todo/index.js.map +1 -1
- package/dist/extensions/todo/schema.d.ts +6 -5
- package/dist/extensions/todo/schema.d.ts.map +1 -1
- package/dist/extensions/todo/schema.js +20 -9
- package/dist/extensions/todo/schema.js.map +1 -1
- package/dist/extensions/todo/state.d.ts +5 -1
- package/dist/extensions/todo/state.d.ts.map +1 -1
- package/dist/extensions/todo/state.js +184 -38
- package/dist/extensions/todo/state.js.map +1 -1
- package/dist/extensions/todo/view.d.ts.map +1 -1
- package/dist/extensions/todo/view.js +10 -6
- package/dist/extensions/todo/view.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +2 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +36 -2
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/utils/coalescing-terminal.d.ts +116 -0
- package/dist/utils/coalescing-terminal.d.ts.map +1 -0
- package/dist/utils/coalescing-terminal.js +256 -0
- package/dist/utils/coalescing-terminal.js.map +1 -0
- package/docs/bundled/extensions/README.md +1 -0
- package/docs/bundled/extensions/plan.md +31 -0
- package/docs/bundled/extensions/question.md +9 -7
- package/docs/bundled/extensions/rewind.md +19 -8
- package/docs/bundled/extensions/subagent.md +16 -16
- package/docs/bundled/extensions/todo.md +5 -3
- package/docs/compaction.md +6 -2
- package/docs/docs.json +4 -0
- package/docs/extensions.md +4 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../../src/extensions/question/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAC/B,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAG9C,MAAM,WAAW,GAAG,CAAC,CAAC;AACtB,MAAM,WAAW,GAAG,CAAC,CAAC;AACtB,MAAM,aAAa,GAAG,CAAC,CAAC;AACxB,MAAM,eAAe,GAAG,CAAC,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../../src/extensions/question/schema.ts"],"names":[],"mappings":"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","sourcesContent":["import { Type } from \"typebox\";\nimport { QUESTION_LIMITS } from \"./limits.ts\";\nimport type { Question, QuestionOption, QuestionToolError } from \"./types.ts\";\n\nconst MIN_OPTIONS = 2;\nconst MAX_OPTIONS = 4;\nconst MAX_QUESTIONS = 4;\nconst RESERVED_LABELS = [\"Other\", \"Type something.\", \"Chat about this\"] as const;\n\nconst normalizeLabel = (text: string) => text.trim().toLowerCase();\nconst RESERVED_NORMALIZED = new Set(RESERVED_LABELS.map(normalizeLabel));\n\nconst OptionSchema = Type.Object({\n\tlabel: Type.String({\n\t\tmaxLength: QUESTION_LIMITS.optionLabelChars,\n\t\tdescription:\n\t\t\t\"Short user-facing option label (1-5 words), distinct within the question. Reserved labels ('Other', 'Type something.', 'Chat about this') are rejected — the UI adds the custom-answer path itself.\",\n\t}),\n\tdescription: Type.String({\n\t\tmaxLength: QUESTION_LIMITS.optionDescriptionChars,\n\t\tdescription: \"One concise sentence explaining the option's meaning, consequence, or tradeoff.\",\n\t}),\n\tpreview: Type.Optional(\n\t\tType.String({\n\t\t\tmaxLength: QUESTION_LIMITS.previewChars,\n\t\t\tdescription:\n\t\t\t\t\"Optional markdown preview shown only for focused single-select options; use for concrete snippets, layouts, copy, or config comparisons.\",\n\t\t}),\n\t),\n});\n\nconst QuestionSchema = Type.Object({\n\tquestion: Type.String({\n\t\tmaxLength: QUESTION_LIMITS.questionChars,\n\t\tdescription:\n\t\t\t\"One clear, specific decision or preference question ending in a question mark. Ask only what is needed to proceed.\",\n\t}),\n\theader: Type.String({\n\t\tmaxLength: QUESTION_LIMITS.headerChars,\n\t\tdescription: \"Very short decision label shown as a chip; aim for ~12 chars, max 32. E.g. 'Auth method'.\",\n\t}),\n\toptions: Type.Array(OptionSchema, {\n\t\tminItems: MIN_OPTIONS,\n\t\tmaxItems: MAX_OPTIONS,\n\t\tdescription: \"Mutually distinct options for this decision; do not include reserved custom-answer labels.\",\n\t}),\n\tmultiSelect: Type.Optional(\n\t\tType.Boolean({\n\t\t\tdescription:\n\t\t\t\t\"Allow selecting multiple options only when choices can be combined. Defaults to false for mutually exclusive decisions.\",\n\t\t}),\n\t),\n});\n\nexport const QuestionParams = Type.Object({\n\tquestions: Type.Array(QuestionSchema, {\n\t\tminItems: 1,\n\t\tmaxItems: MAX_QUESTIONS,\n\t\tdescription: \"Related decisions to ask now (1-4); ask only what is needed to unblock progress.\",\n\t}),\n});\n\n/**\n * Checks the schema cannot express. Length and count limits are omitted:\n * the agent core validates params against `QuestionParams` before execute runs.\n */\nexport function validateQuestions(\n\tquestions: Question[],\n): { ok: true } | { ok: false; error: QuestionToolError; message: string } {\n\tconst seenQuestions = new Set<string>();\n\tfor (const q of questions) {\n\t\tconst questionKey = normalizeLabel(q.question);\n\t\tif (seenQuestions.has(questionKey)) {\n\t\t\treturn { ok: false, error: \"duplicate_question\", message: \"Question text must be unique\" };\n\t\t}\n\t\tseenQuestions.add(questionKey);\n\n\t\tif (q.multiSelect && q.options.some((option) => option.preview)) {\n\t\t\treturn {\n\t\t\t\tok: false,\n\t\t\t\terror: \"preview_multiselect\",\n\t\t\t\tmessage:\n\t\t\t\t\t\"Option previews are not supported on multiSelect questions; drop the previews or make the question single-select\",\n\t\t\t};\n\t\t}\n\n\t\tconst seenLabels = new Set<string>();\n\t\tfor (const option of q.options as QuestionOption[]) {\n\t\t\tconst labelKey = normalizeLabel(option.label);\n\t\t\tif (RESERVED_NORMALIZED.has(labelKey)) {\n\t\t\t\treturn {\n\t\t\t\t\tok: false,\n\t\t\t\t\terror: \"reserved_label\",\n\t\t\t\t\tmessage: `Option label is reserved (${RESERVED_LABELS.join(\", \")})`,\n\t\t\t\t};\n\t\t\t}\n\t\t\tif (seenLabels.has(labelKey)) {\n\t\t\t\treturn {\n\t\t\t\t\tok: false,\n\t\t\t\t\terror: \"duplicate_option_label\",\n\t\t\t\t\tmessage: \"Option labels must be unique within a question\",\n\t\t\t\t};\n\t\t\t}\n\t\t\tseenLabels.add(labelKey);\n\t\t}\n\t}\n\n\treturn { ok: true };\n}\n"]}
|
|
@@ -10,7 +10,7 @@ export interface Question {
|
|
|
10
10
|
multiSelect?: boolean;
|
|
11
11
|
}
|
|
12
12
|
export type InputMode = "custom" | "notes" | undefined;
|
|
13
|
-
export type QuestionToolError = "no_ui" | "
|
|
13
|
+
export type QuestionToolError = "no_ui" | "duplicate_question" | "duplicate_option_label" | "reserved_label" | "preview_multiselect";
|
|
14
14
|
export interface AnswerNote {
|
|
15
15
|
option: string;
|
|
16
16
|
text: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/extensions/question/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,cAAc;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,QAAQ;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,WAAW,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,SAAS,CAAC;AAEvD,MAAM,MAAM,iBAAiB,GAC1B,OAAO,GACP,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/extensions/question/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,cAAc;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,QAAQ;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,WAAW,CAAC,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,SAAS,CAAC;AAEvD,MAAM,MAAM,iBAAiB,GAC1B,OAAO,GACP,oBAAoB,GACpB,wBAAwB,GACxB,gBAAgB,GAChB,qBAAqB,CAAC;AAEzB,MAAM,WAAW,UAAU;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,cAAc;IAC9B,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC;IACpC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,WAAW,GAAG,qBAAqB,GAAG,OAAO,CAAC;AAEzF,MAAM,WAAW,mBAAmB;IACnC,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,OAAO,EAAE,eAAe,CAAC;IACzB,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,iBAAiB,CAAC;CAC1B;AAED,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,aAAa;IAC7B,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,cAAc,CAAC;IAC9B,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,aAAa,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,YAAY;IAC5B,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,OAAO,EAAE,eAAe,CAAC;CACzB;AAED,MAAM,MAAM,aAAa,GACtB,CAAC,cAAc,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAC,GAC1D;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,IAAI,CAAA;CAAE,CAAC;AAEnD,eAAO,MAAM,YAAY,EAAE,aAA0E,CAAC","sourcesContent":["export interface QuestionOption {\n\tlabel: string;\n\tdescription: string;\n\tpreview?: string;\n}\n\nexport interface Question {\n\tquestion: string;\n\theader: string;\n\toptions: QuestionOption[];\n\tmultiSelect?: boolean;\n}\n\nexport type InputMode = \"custom\" | \"notes\" | undefined;\n\nexport type QuestionToolError =\n\t| \"no_ui\"\n\t| \"duplicate_question\"\n\t| \"duplicate_option_label\"\n\t| \"reserved_label\"\n\t| \"preview_multiselect\";\n\nexport interface AnswerNote {\n\toption: string;\n\ttext: string;\n}\n\nexport interface QuestionAnswer {\n\tquestionIndex: number;\n\tquestion: string;\n\theader: string;\n\tkind: \"option\" | \"custom\" | \"multi\";\n\tanswer: string | null;\n\tselected?: string[];\n\tnotes?: AnswerNote[];\n\tpreview?: string;\n}\n\nexport type QuestionOutcome = \"answered\" | \"cancelled\" | \"needs_clarification\" | \"error\";\n\nexport interface QuestionToolDetails {\n\tanswers: QuestionAnswer[];\n\toutcome: QuestionOutcome;\n\tcancelled: boolean;\n\terror?: QuestionToolError;\n}\n\nexport interface CustomAnswer {\n\ttext: string;\n\tselected: boolean;\n}\n\nexport interface QuestionState {\n\toptionIndex: number;\n\tsingleAnswer?: QuestionAnswer;\n\tmultiSelected: Set<number>;\n\tcustomAnswer?: CustomAnswer;\n\tnotesByOption: Map<string, string>;\n\twarning?: string;\n}\n\nexport interface DialogResult {\n\tanswers: QuestionAnswer[];\n\toutcome: QuestionOutcome;\n}\n\nexport type DisplayOption =\n\t| (QuestionOption & { kind: \"option\"; optionIndex: number })\n\t| { kind: \"other\"; label: string; isOther: true };\n\nexport const OTHER_OPTION: DisplayOption = { kind: \"other\", label: \"Type something.\", isOther: true };\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/extensions/question/types.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/extensions/question/types.ts"],"names":[],"mappings":"AAsEA,MAAM,CAAC,MAAM,YAAY,GAAkB,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,iBAAiB,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC","sourcesContent":["export interface QuestionOption {\n\tlabel: string;\n\tdescription: string;\n\tpreview?: string;\n}\n\nexport interface Question {\n\tquestion: string;\n\theader: string;\n\toptions: QuestionOption[];\n\tmultiSelect?: boolean;\n}\n\nexport type InputMode = \"custom\" | \"notes\" | undefined;\n\nexport type QuestionToolError =\n\t| \"no_ui\"\n\t| \"duplicate_question\"\n\t| \"duplicate_option_label\"\n\t| \"reserved_label\"\n\t| \"preview_multiselect\";\n\nexport interface AnswerNote {\n\toption: string;\n\ttext: string;\n}\n\nexport interface QuestionAnswer {\n\tquestionIndex: number;\n\tquestion: string;\n\theader: string;\n\tkind: \"option\" | \"custom\" | \"multi\";\n\tanswer: string | null;\n\tselected?: string[];\n\tnotes?: AnswerNote[];\n\tpreview?: string;\n}\n\nexport type QuestionOutcome = \"answered\" | \"cancelled\" | \"needs_clarification\" | \"error\";\n\nexport interface QuestionToolDetails {\n\tanswers: QuestionAnswer[];\n\toutcome: QuestionOutcome;\n\tcancelled: boolean;\n\terror?: QuestionToolError;\n}\n\nexport interface CustomAnswer {\n\ttext: string;\n\tselected: boolean;\n}\n\nexport interface QuestionState {\n\toptionIndex: number;\n\tsingleAnswer?: QuestionAnswer;\n\tmultiSelected: Set<number>;\n\tcustomAnswer?: CustomAnswer;\n\tnotesByOption: Map<string, string>;\n\twarning?: string;\n}\n\nexport interface DialogResult {\n\tanswers: QuestionAnswer[];\n\toutcome: QuestionOutcome;\n}\n\nexport type DisplayOption =\n\t| (QuestionOption & { kind: \"option\"; optionIndex: number })\n\t| { kind: \"other\"; label: string; isOther: true };\n\nexport const OTHER_OPTION: DisplayOption = { kind: \"other\", label: \"Type something.\", isOther: true };\n"]}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* anchor.ts — pure helper for choosing a snapshot frame's turn anchor.
|
|
3
|
+
*
|
|
4
|
+
* A frame records the work tree as of the moment its agent run started
|
|
5
|
+
* (beginTurn at before_agent_start). The entry representing that moment is the
|
|
6
|
+
* FIRST user entry the run appended — not the last user entry on the branch:
|
|
7
|
+
* steering / follow-up messages consumed inside the same run append later user
|
|
8
|
+
* entries, and anchoring to those would attach "start of run" file state to a
|
|
9
|
+
* mid-run conversation point.
|
|
10
|
+
*
|
|
11
|
+
* No Pi imports, so the module stays node-testable (see storage.ts).
|
|
12
|
+
*/
|
|
13
|
+
/** Structural view of a branch entry (SessionEntry fits loosely). */
|
|
14
|
+
export interface BranchEntryLike {
|
|
15
|
+
id: string;
|
|
16
|
+
type: string;
|
|
17
|
+
message?: {
|
|
18
|
+
role?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The id of the first user message after `scanStartId` in `branch`
|
|
23
|
+
* (root → leaf order). `scanStartId === null` scans from the root (fresh
|
|
24
|
+
* session). Returns undefined when `scanStartId` is not on the branch (the
|
|
25
|
+
* leaf moved across branches since it was recorded) — anchoring against an
|
|
26
|
+
* unrelated prefix would mislabel the frame, so callers should discard it.
|
|
27
|
+
*/
|
|
28
|
+
export declare function firstUserEntryIdAfter(branch: readonly BranchEntryLike[], scanStartId: string | null): string | undefined;
|
|
29
|
+
//# sourceMappingURL=anchor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"anchor.d.ts","sourceRoot":"","sources":["../../../src/extensions/rewind/anchor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,qEAAqE;AACrE,MAAM,WAAW,eAAe;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC5B;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACpC,MAAM,EAAE,SAAS,eAAe,EAAE,EAClC,WAAW,EAAE,MAAM,GAAG,IAAI,GACxB,MAAM,GAAG,SAAS,CAYpB","sourcesContent":["/**\n * anchor.ts — pure helper for choosing a snapshot frame's turn anchor.\n *\n * A frame records the work tree as of the moment its agent run started\n * (beginTurn at before_agent_start). The entry representing that moment is the\n * FIRST user entry the run appended — not the last user entry on the branch:\n * steering / follow-up messages consumed inside the same run append later user\n * entries, and anchoring to those would attach \"start of run\" file state to a\n * mid-run conversation point.\n *\n * No Pi imports, so the module stays node-testable (see storage.ts).\n */\n\n/** Structural view of a branch entry (SessionEntry fits loosely). */\nexport interface BranchEntryLike {\n\tid: string;\n\ttype: string;\n\tmessage?: { role?: string };\n}\n\n/**\n * The id of the first user message after `scanStartId` in `branch`\n * (root → leaf order). `scanStartId === null` scans from the root (fresh\n * session). Returns undefined when `scanStartId` is not on the branch (the\n * leaf moved across branches since it was recorded) — anchoring against an\n * unrelated prefix would mislabel the frame, so callers should discard it.\n */\nexport function firstUserEntryIdAfter(\n\tbranch: readonly BranchEntryLike[],\n\tscanStartId: string | null,\n): string | undefined {\n\tlet start = 0;\n\tif (scanStartId !== null) {\n\t\tconst idx = branch.findIndex((e) => e.id === scanStartId);\n\t\tif (idx < 0) return undefined;\n\t\tstart = idx + 1;\n\t}\n\tfor (let i = start; i < branch.length; i++) {\n\t\tconst e = branch[i];\n\t\tif (e.type === \"message\" && e.message?.role === \"user\") return e.id;\n\t}\n\treturn undefined;\n}\n"]}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* anchor.ts — pure helper for choosing a snapshot frame's turn anchor.
|
|
3
|
+
*
|
|
4
|
+
* A frame records the work tree as of the moment its agent run started
|
|
5
|
+
* (beginTurn at before_agent_start). The entry representing that moment is the
|
|
6
|
+
* FIRST user entry the run appended — not the last user entry on the branch:
|
|
7
|
+
* steering / follow-up messages consumed inside the same run append later user
|
|
8
|
+
* entries, and anchoring to those would attach "start of run" file state to a
|
|
9
|
+
* mid-run conversation point.
|
|
10
|
+
*
|
|
11
|
+
* No Pi imports, so the module stays node-testable (see storage.ts).
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* The id of the first user message after `scanStartId` in `branch`
|
|
15
|
+
* (root → leaf order). `scanStartId === null` scans from the root (fresh
|
|
16
|
+
* session). Returns undefined when `scanStartId` is not on the branch (the
|
|
17
|
+
* leaf moved across branches since it was recorded) — anchoring against an
|
|
18
|
+
* unrelated prefix would mislabel the frame, so callers should discard it.
|
|
19
|
+
*/
|
|
20
|
+
export function firstUserEntryIdAfter(branch, scanStartId) {
|
|
21
|
+
let start = 0;
|
|
22
|
+
if (scanStartId !== null) {
|
|
23
|
+
const idx = branch.findIndex((e) => e.id === scanStartId);
|
|
24
|
+
if (idx < 0)
|
|
25
|
+
return undefined;
|
|
26
|
+
start = idx + 1;
|
|
27
|
+
}
|
|
28
|
+
for (let i = start; i < branch.length; i++) {
|
|
29
|
+
const e = branch[i];
|
|
30
|
+
if (e.type === "message" && e.message?.role === "user")
|
|
31
|
+
return e.id;
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=anchor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"anchor.js","sourceRoot":"","sources":["../../../src/extensions/rewind/anchor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AASH;;;;;;GAMG;AACH,MAAM,UAAU,qBAAqB,CACpC,MAAkC,EAClC,WAA0B,EACL;IACrB,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,IAAI,WAAW,KAAK,IAAI,EAAE,CAAC;QAC1B,MAAM,GAAG,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,WAAW,CAAC,CAAC;QAC1D,IAAI,GAAG,GAAG,CAAC;YAAE,OAAO,SAAS,CAAC;QAC9B,KAAK,GAAG,GAAG,GAAG,CAAC,CAAC;IACjB,CAAC;IACD,KAAK,IAAI,CAAC,GAAG,KAAK,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAC5C,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;QACpB,IAAI,CAAC,CAAC,IAAI,KAAK,SAAS,IAAI,CAAC,CAAC,OAAO,EAAE,IAAI,KAAK,MAAM;YAAE,OAAO,CAAC,CAAC,EAAE,CAAC;IACrE,CAAC;IACD,OAAO,SAAS,CAAC;AAAA,CACjB","sourcesContent":["/**\n * anchor.ts — pure helper for choosing a snapshot frame's turn anchor.\n *\n * A frame records the work tree as of the moment its agent run started\n * (beginTurn at before_agent_start). The entry representing that moment is the\n * FIRST user entry the run appended — not the last user entry on the branch:\n * steering / follow-up messages consumed inside the same run append later user\n * entries, and anchoring to those would attach \"start of run\" file state to a\n * mid-run conversation point.\n *\n * No Pi imports, so the module stays node-testable (see storage.ts).\n */\n\n/** Structural view of a branch entry (SessionEntry fits loosely). */\nexport interface BranchEntryLike {\n\tid: string;\n\ttype: string;\n\tmessage?: { role?: string };\n}\n\n/**\n * The id of the first user message after `scanStartId` in `branch`\n * (root → leaf order). `scanStartId === null` scans from the root (fresh\n * session). Returns undefined when `scanStartId` is not on the branch (the\n * leaf moved across branches since it was recorded) — anchoring against an\n * unrelated prefix would mislabel the frame, so callers should discard it.\n */\nexport function firstUserEntryIdAfter(\n\tbranch: readonly BranchEntryLike[],\n\tscanStartId: string | null,\n): string | undefined {\n\tlet start = 0;\n\tif (scanStartId !== null) {\n\t\tconst idx = branch.findIndex((e) => e.id === scanStartId);\n\t\tif (idx < 0) return undefined;\n\t\tstart = idx + 1;\n\t}\n\tfor (let i = start; i < branch.length; i++) {\n\t\tconst e = branch[i];\n\t\tif (e.type === \"message\" && e.message?.role === \"user\") return e.id;\n\t}\n\treturn undefined;\n}\n"]}
|
|
@@ -42,12 +42,6 @@ export declare function getSnapshots(sid: string): FileHistorySnapshot[];
|
|
|
42
42
|
* Sets state.dirty when any file produced a new version.
|
|
43
43
|
*/
|
|
44
44
|
export declare function beginTurn(sid: string): Promise<void>;
|
|
45
|
-
/**
|
|
46
|
-
* Back up a file about to be edited/written, if not already captured this turn.
|
|
47
|
-
* Call from the tool_call hook BEFORE the edit lands. ENOENT target -> null
|
|
48
|
-
* marker (rewind deletes the created file). Synchronous so the backup is on disk
|
|
49
|
-
* before the hook returns (see createBackupSync).
|
|
50
|
-
*/
|
|
51
45
|
export declare function trackEdit(sid: string, absPath: string): void;
|
|
52
46
|
/**
|
|
53
47
|
* Finalize the turn. Returns the snapshot to persist (caller appendEntry's it),
|
|
@@ -85,6 +79,8 @@ export declare function collectChangeDiffStats(sid: string, target: FileHistoryS
|
|
|
85
79
|
* pass and only touch those absolute paths.
|
|
86
80
|
*/
|
|
87
81
|
export declare function applySnapshot(sid: string, target: FileHistorySnapshot, opts?: ApplySnapshotOptions): Promise<string[]>;
|
|
82
|
+
/** The trailing window of `snapshots` retained under the cap (endTurn's ring). */
|
|
83
|
+
export declare function capSnapshots(snapshots: FileHistorySnapshot[], maxSnapshots?: number): FileHistorySnapshot[];
|
|
88
84
|
/** Rebuild in-memory state from snapshots persisted in the session JSONL. */
|
|
89
85
|
export declare function restoreStateFromSnapshots(sid: string, cwd: string, snapshots: FileHistorySnapshot[], maxSnapshots?: number): void;
|
|
90
86
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../../src/extensions/rewind/engine.ts"],"names":[],"mappings":"AAiCA,OAAO,KAAK,EAAE,UAAU,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAcrE,qFAAqF;AACrF,UAAU,SAAS;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACb;AAUD,MAAM,WAAW,gBAAgB;IAChC,kDAAkD;IAClD,SAAS,EAAE,mBAAmB,EAAE,CAAC;IACjC,mDAAmD;IACnD,YAAY,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC1B,kFAAkF;IAClF,gBAAgB,EAAE,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;IAC1C;;;;OAIG;IACH,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IACjC,sFAAsF;IACtF,OAAO,EAAE,mBAAmB,GAAG,IAAI,CAAC;IACpC,uEAAuE;IACvE,KAAK,EAAE,OAAO,CAAC;IACf,yEAAyE;IACzE,GAAG,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,oBAAoB;IACpC;;;OAGG;IACH,SAAS,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CAChC;AAwDD,sFAAsF;AACtF,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAG9D;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAGhD;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,mBAAmB,EAAE,CAE/D;AAqOD;;;;GAIG;AACH,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAqD1D;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CA2B5D;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CACtB,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,YAAY,SAAgB,GAC1B,mBAAmB,GAAG,IAAI,CAkB5B;AAiCD;;;;;GAKG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAkBhG;AAED,MAAM,WAAW,eAAe;IAC/B,iEAAiE;IACjE,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB;;;OAGG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,sFAAsF;IACtF,SAAS,EAAE,MAAM,CAAC;CAClB;AAED;;;;GAIG;AACH,wBAAsB,sBAAsB,CAC3C,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,mBAAmB,EAC3B,YAAY,EAAE,SAAS,MAAM,EAAE,GAC7B,OAAO,CAAC,eAAe,CAAC,CA+B1B;AAkFD;;;;GAIG;AACH,wBAAsB,aAAa,CAClC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,mBAAmB,EAC3B,IAAI,CAAC,EAAE,oBAAoB,GACzB,OAAO,CAAC,MAAM,EAAE,CAAC,CAuCnB;AAID,6EAA6E;AAC7E,wBAAgB,yBAAyB,CACxC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,mBAAmB,EAAE,EAChC,YAAY,SAAgB,GAC1B,IAAI,CAyBN;AAED;;;;GAIG;AACH,wBAAsB,yBAAyB,CAC9C,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,mBAAmB,EAAE,GAC9B,OAAO,CAAC,IAAI,CAAC,CAyBf","sourcesContent":["/**\n * engine.ts — file-history backup engine for the rewind extension.\n *\n * Ported from Claude Code 2.1.88's src/utils/fileHistory.ts, adapted from its\n * React state-updater to module-level per-session maps. The core idea (and the\n * reason this exists): we back up ONLY the files Pi's edit/write tools are about\n * to modify — one copyFile before the edit — instead of snapshotting the whole\n * work tree. Cost is proportional to \"how many files Pi changed\", not project\n * size, so it never blocks the session-lifecycle critical path and storage stays\n * tiny.\n *\n * Per turn:\n * - beginTurn() (before_agent_start): open a working frame and re-record every\n * already-tracked file at its current (turn-start) state, reusing the latest\n * backup when unchanged (worktree lastSeen → mtime/size/content), creating a\n * new version when not. lastSeen skips backup-blob IO when the worktree has\n * not moved since the last confirm; content compare streams in 64 KiB chunks.\n * - trackEdit() (tool_call edit|write, before the write): back up a *newly*\n * edited file at its pre-edit state into the working frame (null marker when\n * the target does not exist yet, so rewind deletes the created file).\n * - endTurn() (agent_settled): if anything changed, stamp + return the frame\n * to persist; else discard it. agent_settled (not agent_end) so auto-retry /\n * overflow compact-retry / queued follow-ups share one logical turn.\n *\n * Rewind = applySnapshot(): restore every tracked file to the version recorded in\n * the target frame (copy back / delete for null), touching only files that differ.\n *\n * Backups live at <rewindBackupsDir(sessionId)>/<sha256(relpath)[:16]>@v<n>.\n */\nimport { createHash } from \"node:crypto\";\nimport { chmodSync, copyFileSync, mkdirSync, type Stats, statSync } from \"node:fs\";\nimport { chmod, copyFile, link, mkdir, open, readFile, stat, unlink } from \"node:fs/promises\";\nimport { dirname, isAbsolute, join, relative } from \"node:path\";\nimport type { FileBackup, FileHistorySnapshot } from \"./snapshot.ts\";\nimport { backupsDir } from \"./storage.ts\";\n\n/** Cap on retained snapshots per session (matches CC's MAX_SNAPSHOTS). */\nconst MAX_SNAPSHOTS = 100;\n/** Avoid reading very large files solely to compare backup content. */\nconst MAX_CONTENT_BYTES = 25 * 1024 * 1024;\n/** Cap concurrent backup/compare/restore IO so long tracked sets don't thrash handles. */\nconst IO_CONCURRENCY = 16;\n/** Cap bytes loaded for coarse restore line stats (confirm dialog only). */\nconst MAX_DIFF_BYTES = 1_048_576;\n/** Streaming compare chunk size (avoids buffering whole files up to MAX_CONTENT_BYTES). */\nconst COMPARE_CHUNK_BYTES = 64 * 1024;\n\n/** Worktree identity recorded when we last backed up / confirmed a tracking path. */\ninterface SeenStats {\n\tmtimeMs: number;\n\tsize: number;\n\tmode: number;\n}\n\nfunction seenFromStats(st: Stats): SeenStats {\n\treturn { mtimeMs: st.mtimeMs, size: st.size, mode: st.mode };\n}\n\nfunction sameSeen(a: SeenStats, b: Stats): boolean {\n\treturn a.mtimeMs === b.mtimeMs && a.size === b.size && a.mode === b.mode;\n}\n\nexport interface FileHistoryState {\n\t/** Finalized + persisted frames, oldest first. */\n\tsnapshots: FileHistorySnapshot[];\n\t/** All tracking-paths ever edited this session. */\n\ttrackedFiles: Set<string>;\n\t/** Latest backup per tracking path (finalized + in-progress pending versions). */\n\tlatestByTracking: Map<string, FileBackup>;\n\t/**\n\t * Worktree mtime/size/mode at the last backup or unchanged confirmation.\n\t * beginTurn uses this to skip backup-blob stats/content when the worktree\n\t * has not moved since we last recorded it (still stats the worktree once).\n\t */\n\tlastSeen: Map<string, SeenStats>;\n\t/** The current turn's working frame (built across the turn, persisted at endTurn). */\n\tpending: FileHistorySnapshot | null;\n\t/** Whether the pending frame differs from the last finalized frame. */\n\tdirty: boolean;\n\t/** Monotonic activity counter (incremented on every finalized frame). */\n\tseq: number;\n}\n\nexport interface ApplySnapshotOptions {\n\t/**\n\t * Absolute paths already known to differ (e.g. from collectChanges).\n\t * When set, only those paths are restored/deleted and originChanged is skipped.\n\t */\n\tonlyPaths?: ReadonlySet<string>;\n}\n\n// ---- per-session state ----------------------------------------------------\n\nconst states = new Map<string, FileHistoryState>();\nconst cwds = new Map<string, string>();\n\nfunction freshState(): FileHistoryState {\n\treturn {\n\t\tsnapshots: [],\n\t\ttrackedFiles: new Set(),\n\t\tlatestByTracking: new Map(),\n\t\tlastSeen: new Map(),\n\t\tpending: null,\n\t\tdirty: false,\n\t\tseq: 0,\n\t};\n}\n\n/** Run async work over items with a fixed concurrency cap. Preserves result order. */\nasync function mapPool<T, R>(items: readonly T[], concurrency: number, fn: (item: T) => Promise<R>): Promise<R[]> {\n\tif (items.length === 0) return [];\n\tconst results = new Array<R>(items.length);\n\tlet next = 0;\n\tasync function worker(): Promise<void> {\n\t\tfor (;;) {\n\t\t\tconst i = next++;\n\t\t\tif (i >= items.length) return;\n\t\t\tresults[i] = await fn(items[i]!);\n\t\t}\n\t}\n\tconst n = Math.min(concurrency, items.length);\n\tawait Promise.all(Array.from({ length: n }, () => worker()));\n\treturn results;\n}\n\n/** Rebuild latestByTracking from finalized frames (oldest → newest, last write wins). */\nfunction rebuildLatestIndex(snapshots: FileHistorySnapshot[]): Map<string, FileBackup> {\n\tconst latest = new Map<string, FileBackup>();\n\tfor (const snap of snapshots) {\n\t\tfor (const [tracking, backup] of Object.entries(snap.trackedFileBackups)) {\n\t\t\tlatest.set(tracking, backup);\n\t\t}\n\t}\n\treturn latest;\n}\n\nfunction getState(sid: string): FileHistoryState {\n\tlet s = states.get(sid);\n\tif (!s) {\n\t\ts = freshState();\n\t\tstates.set(sid, s);\n\t}\n\treturn s;\n}\n\n/** Bind a session id to its cwd (for relative-path keying). Call at session_start. */\nexport function registerSession(sid: string, cwd: string): void {\n\tcwds.set(sid, cwd);\n\tgetState(sid);\n}\n\nexport function disposeSession(sid: string): void {\n\tstates.delete(sid);\n\tcwds.delete(sid);\n}\n\nexport function getSnapshots(sid: string): FileHistorySnapshot[] {\n\treturn states.get(sid)?.snapshots ?? [];\n}\n\nfunction cwdFor(sid: string): string {\n\treturn cwds.get(sid) ?? process.cwd();\n}\n\n// ---- path helpers ---------------------------------------------------------\n\n/** Use the cwd-relative path as the tracking key when inside cwd (shorter, portable). */\nfunction shorten(absPath: string, cwd: string): string {\n\tif (!isAbsolute(absPath)) return absPath;\n\tif (absPath === cwd) return absPath;\n\tconst rel = relative(cwd, absPath);\n\tif (!rel || rel.startsWith(\"..\") || isAbsolute(rel)) return absPath;\n\treturn rel;\n}\n\nfunction expand(tracking: string, cwd: string): string {\n\treturn isAbsolute(tracking) ? tracking : join(cwd, tracking);\n}\n\nfunction backupName(tracking: string, version: number): string {\n\tconst h = createHash(\"sha256\").update(tracking).digest(\"hex\").slice(0, 16);\n\treturn `${h}@v${version}`;\n}\n\nfunction backupPathFor(sid: string, name: string): string {\n\treturn join(backupsDir(sid), name);\n}\n\nfunction latestBackupOf(state: FileHistoryState, tracking: string): FileBackup | undefined {\n\treturn state.latestByTracking.get(tracking);\n}\n\n/** First-ever recorded backup for a file (used when rewinding before it was tracked). */\nfunction firstBackupName(state: FileHistoryState, tracking: string): string | null | undefined {\n\tfor (const snap of state.snapshots) {\n\t\tconst b = snap.trackedFileBackups[tracking];\n\t\tif (b && b.version === 1) return b.backupName;\n\t}\n\treturn undefined;\n}\n\n/** Resolve the backup blob name for `tracking` at `target` (null = did not exist). */\nfunction backupNameForTarget(\n\tstate: FileHistoryState,\n\ttarget: FileHistorySnapshot,\n\ttracking: string,\n): string | null | undefined {\n\tconst tb = target.trackedFileBackups[tracking];\n\tif (tb) return tb.backupName;\n\treturn firstBackupName(state, tracking);\n}\n\n// ---- change detection (ported fileHistory.ts compareStatsAndContent;\n// content path streams via filesEqualChunked instead of full-buffer reads)\n\nfunction isENOENT(e: unknown): boolean {\n\treturn !!e && typeof e === \"object\" && (e as { code?: string }).code === \"ENOENT\";\n}\n\nasync function statOrNull(p: string): Promise<Stats | null> {\n\ttry {\n\t\treturn await stat(p);\n\t} catch (e) {\n\t\tif (isENOENT(e)) return null;\n\t\tthrow e;\n\t}\n}\n\n/**\n * Raw-byte equality without buffering whole files. A utf-8 decode maps invalid\n * sequences to U+FFFD, which can make two DIFFERENT binary files compare equal —\n * and then rewind would silently skip both the re-backup and the restore.\n */\nasync function filesEqualChunked(aPath: string, bPath: string, size: number): Promise<boolean> {\n\tif (size === 0) return true;\n\tconst [aHandle, bHandle] = await Promise.all([open(aPath, \"r\"), open(bPath, \"r\")]);\n\ttry {\n\t\tconst aBuf = Buffer.allocUnsafe(COMPARE_CHUNK_BYTES);\n\t\tconst bBuf = Buffer.allocUnsafe(COMPARE_CHUNK_BYTES);\n\t\tlet remaining = size;\n\t\twhile (remaining > 0) {\n\t\t\tconst want = Math.min(COMPARE_CHUNK_BYTES, remaining);\n\t\t\tconst [aRead, bRead] = await Promise.all([\n\t\t\t\taHandle.read(aBuf, 0, want, null),\n\t\t\t\tbHandle.read(bBuf, 0, want, null),\n\t\t\t]);\n\t\t\tif (aRead.bytesRead !== bRead.bytesRead) return false;\n\t\t\tif (aRead.bytesRead === 0) return remaining === 0;\n\t\t\tif (!aBuf.subarray(0, aRead.bytesRead).equals(bBuf.subarray(0, bRead.bytesRead))) return false;\n\t\t\tremaining -= aRead.bytesRead;\n\t\t}\n\t\treturn true;\n\t} finally {\n\t\tawait Promise.allSettled([aHandle.close(), bHandle.close()]);\n\t}\n}\n\n/** True when the on-disk file differs from its backup blob. */\nasync function originChanged(sid: string, filePath: string, name: string, hint?: Stats): Promise<boolean> {\n\tconst backupPath = backupPathFor(sid, name);\n\tconst orig = hint ?? (await statOrNull(filePath).catch(() => null));\n\tconst back = await statOrNull(backupPath).catch(() => null);\n\n\t// One exists, one missing -> changed.\n\tif ((orig === null) !== (back === null)) return true;\n\tif (orig === null || back === null) return false;\n\tif (orig.mode !== back.mode || orig.size !== back.size) return true;\n\t// Original untouched since the backup was written -> unchanged (skip content read).\n\tif (orig.mtimeMs < back.mtimeMs) return false;\n\t// Memory guard: don't load huge files just to byte-compare; assume changed and\n\t// let createBackup stream a new version instead (copyFile never buffers whole files).\n\tif (orig.size > MAX_CONTENT_BYTES) return true;\n\ttry {\n\t\treturn !(await filesEqualChunked(filePath, backupPath, orig.size));\n\t} catch {\n\t\treturn true;\n\t}\n}\n\n// ---- backup / restore IO --------------------------------------------------\n\n/** Copy the file's current contents into a backup blob; null backup when absent. */\nasync function createBackup(\n\tsid: string,\n\tfilePath: string,\n\ttracking: string,\n\tversion: number,\n\tknown?: Stats | null,\n): Promise<FileBackup> {\n\tlet src: Stats;\n\tif (known === null) return { backupName: null, version };\n\tif (known) {\n\t\tsrc = known;\n\t} else {\n\t\ttry {\n\t\t\tsrc = await stat(filePath);\n\t\t} catch (e) {\n\t\t\tif (isENOENT(e)) return { backupName: null, version };\n\t\t\tthrow e;\n\t\t}\n\t}\n\tconst name = backupName(tracking, version);\n\tconst dest = backupPathFor(sid, name);\n\ttry {\n\t\tawait copyFile(filePath, dest);\n\t} catch (e) {\n\t\tif (!isENOENT(e)) throw e;\n\t\tawait mkdir(dirname(dest), { recursive: true });\n\t\tawait copyFile(filePath, dest);\n\t}\n\ttry {\n\t\tawait chmod(dest, src.mode);\n\t} catch {\n\t\t// best-effort (e.g. Windows); content is what matters\n\t}\n\treturn { backupName: name, version };\n}\n\n/**\n * Synchronous backup, used on the edit critical path (tool_call). Doing the copy\n * synchronously guarantees it completes before the hook returns control to the\n * host — so the backup always captures the file's PRE-edit contents, regardless\n * of whether the host awaits the hook before running the tool.\n *\n * `known` reuses a pre-stat from the caller (`null` = known ENOENT) so trackEdit\n * can record lastSeen without a second statSync on the critical path.\n */\nfunction createBackupSync(\n\tsid: string,\n\tfilePath: string,\n\ttracking: string,\n\tversion: number,\n\tknown?: Stats | null,\n): FileBackup {\n\tlet src: Stats;\n\tif (known === null) return { backupName: null, version };\n\tif (known) {\n\t\tsrc = known;\n\t} else {\n\t\ttry {\n\t\t\tsrc = statSync(filePath);\n\t\t} catch (e) {\n\t\t\tif (isENOENT(e)) return { backupName: null, version };\n\t\t\tthrow e;\n\t\t}\n\t}\n\tconst name = backupName(tracking, version);\n\tconst dest = backupPathFor(sid, name);\n\ttry {\n\t\tcopyFileSync(filePath, dest);\n\t} catch (e) {\n\t\tif (!isENOENT(e)) throw e;\n\t\tmkdirSync(dirname(dest), { recursive: true });\n\t\tcopyFileSync(filePath, dest);\n\t}\n\ttry {\n\t\tchmodSync(dest, src.mode);\n\t} catch {\n\t\t// best-effort\n\t}\n\treturn { backupName: name, version };\n}\n\nasync function restoreBackup(sid: string, filePath: string, name: string): Promise<void> {\n\tconst backupPath = backupPathFor(sid, name);\n\tlet back: Stats;\n\ttry {\n\t\tback = await stat(backupPath);\n\t} catch {\n\t\treturn; // backup vanished; leave the file untouched\n\t}\n\ttry {\n\t\tawait copyFile(backupPath, filePath);\n\t} catch (e) {\n\t\tif (!isENOENT(e)) throw e;\n\t\tawait mkdir(dirname(filePath), { recursive: true });\n\t\tawait copyFile(backupPath, filePath);\n\t}\n\ttry {\n\t\tawait chmod(filePath, back.mode);\n\t} catch {\n\t\t// best-effort\n\t}\n}\n\n// ---- per-turn lifecycle ---------------------------------------------------\n\n/**\n * Open the working frame for a turn and re-record every tracked file at its\n * current state (reuse latest backup when unchanged, new version when changed).\n * Sets state.dirty when any file produced a new version.\n */\nexport async function beginTurn(sid: string): Promise<void> {\n\tconst state = getState(sid);\n\tconst cwd = cwdFor(sid);\n\tconst backups: Record<string, FileBackup> = {};\n\tlet dirty = false;\n\n\tawait mapPool(Array.from(state.trackedFiles), IO_CONCURRENCY, async (tracking) => {\n\t\ttry {\n\t\t\tconst filePath = expand(tracking, cwd);\n\t\t\tconst latest = latestBackupOf(state, tracking);\n\t\t\tconst nextVersion = latest ? latest.version + 1 : 1;\n\t\t\tconst st = await statOrNull(filePath);\n\t\t\tif (!st) {\n\t\t\t\t// Already recorded absent in the latest frame -> reuse it. Allocating a\n\t\t\t\t// fresh null version every turn would pin dirty=true forever once a\n\t\t\t\t// tracked file is deleted, defeating the \"skip unchanged turns\" check\n\t\t\t\t// and flushing real checkpoints out of the capped ring.\n\t\t\t\tstate.lastSeen.delete(tracking);\n\t\t\t\tif (latest && latest.backupName === null) {\n\t\t\t\t\tbackups[tracking] = latest;\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst absent: FileBackup = { backupName: null, version: nextVersion };\n\t\t\t\tbackups[tracking] = absent;\n\t\t\t\tstate.latestByTracking.set(tracking, absent);\n\t\t\t\tdirty = true;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// Worktree identity unchanged since last backup/confirm → reuse without\n\t\t\t// opening the backup blob (still paid one worktree stat above).\n\t\t\tconst seen = state.lastSeen.get(tracking);\n\t\t\tif (latest && latest.backupName !== null && seen && sameSeen(seen, st)) {\n\t\t\t\tbackups[tracking] = latest;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (latest && latest.backupName !== null && !(await originChanged(sid, filePath, latest.backupName, st))) {\n\t\t\t\tbackups[tracking] = latest; // unchanged -> reuse\n\t\t\t\tstate.lastSeen.set(tracking, seenFromStats(st));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst created = await createBackup(sid, filePath, tracking, nextVersion, st);\n\t\t\tbackups[tracking] = created;\n\t\t\tstate.latestByTracking.set(tracking, created);\n\t\t\t// Record the pre-backup worktree identity (copyFile does not change it).\n\t\t\tstate.lastSeen.set(tracking, seenFromStats(st));\n\t\t\tdirty = true;\n\t\t} catch {\n\t\t\t// skip this file; never break the turn\n\t\t}\n\t});\n\n\tstate.pending = { v: 1, userEntryId: \"\", turnId: \"\", prompt: \"\", trackedFileBackups: backups, timestamp: \"\" };\n\tstate.dirty = dirty;\n}\n\n/**\n * Back up a file about to be edited/written, if not already captured this turn.\n * Call from the tool_call hook BEFORE the edit lands. ENOENT target -> null\n * marker (rewind deletes the created file). Synchronous so the backup is on disk\n * before the hook returns (see createBackupSync).\n */\nexport function trackEdit(sid: string, absPath: string): void {\n\tconst state = getState(sid);\n\tconst cwd = cwdFor(sid);\n\tconst tracking = shorten(absPath, cwd);\n\n\tif (!state.pending) {\n\t\tstate.pending = { v: 1, userEntryId: \"\", turnId: \"\", prompt: \"\", trackedFileBackups: {}, timestamp: \"\" };\n\t}\n\tif (state.pending.trackedFileBackups[tracking]) return; // already captured this turn\n\n\tconst latest = latestBackupOf(state, tracking);\n\tconst version = latest ? latest.version + 1 : 1;\n\tconst filePath = expand(tracking, cwd);\n\t// One pre-edit stat: drives the backup and lastSeen (null = new-file marker).\n\tlet preEdit: Stats | null = null;\n\ttry {\n\t\tpreEdit = statSync(filePath);\n\t} catch (e) {\n\t\tif (!isENOENT(e)) throw e;\n\t}\n\tconst backup = createBackupSync(sid, filePath, tracking, version, preEdit);\n\tstate.pending.trackedFileBackups[tracking] = backup;\n\tstate.trackedFiles.add(tracking);\n\tstate.latestByTracking.set(tracking, backup);\n\tif (preEdit) state.lastSeen.set(tracking, seenFromStats(preEdit));\n\telse state.lastSeen.delete(tracking);\n\tstate.dirty = true;\n}\n\n/**\n * Finalize the turn. Returns the snapshot to persist (caller appendEntry's it),\n * or null when nothing changed this turn. Pushes finalized frames into the\n * in-memory ring (capped at MAX_SNAPSHOTS).\n */\nexport function endTurn(\n\tsid: string,\n\tuserEntryId: string,\n\tturnId: string,\n\tprompt: string,\n\ttimestamp: string,\n\tmaxSnapshots = MAX_SNAPSHOTS,\n): FileHistorySnapshot | null {\n\tconst state = getState(sid);\n\tconst pending = state.pending;\n\tstate.pending = null;\n\tif (!pending || !state.dirty) {\n\t\tstate.dirty = false;\n\t\treturn null;\n\t}\n\tstate.dirty = false;\n\tconst frame: FileHistorySnapshot = { ...pending, userEntryId, turnId, prompt, timestamp };\n\tstate.snapshots.push(frame);\n\tif (state.snapshots.length > maxSnapshots) {\n\t\tconst dropped = state.snapshots.slice(0, state.snapshots.length - maxSnapshots);\n\t\tstate.snapshots = state.snapshots.slice(-maxSnapshots);\n\t\tvoid pruneDroppedBlobs(sid, dropped, state.snapshots);\n\t}\n\tstate.seq++;\n\treturn frame;\n}\n\n/**\n * Best-effort deletion of backup blobs referenced ONLY by frames dropped from\n * the capped ring. Backups are reused across frames (an unchanged file keeps\n * pointing at the same version), so a blob is unlinked only when no retained\n * frame still references it. Without this, the cap trims the in-memory index\n * while the blob files accumulate for the whole session — gc.ts only reclaims\n * whole session directories.\n */\nasync function pruneDroppedBlobs(\n\tsid: string,\n\tdropped: FileHistorySnapshot[],\n\tretained: FileHistorySnapshot[],\n): Promise<void> {\n\tconst live = new Set<string>();\n\tfor (const snap of retained) {\n\t\tfor (const b of Object.values(snap.trackedFileBackups)) {\n\t\t\tif (b.backupName) live.add(b.backupName);\n\t\t}\n\t}\n\tconst doomed = new Set<string>();\n\tfor (const snap of dropped) {\n\t\tfor (const b of Object.values(snap.trackedFileBackups)) {\n\t\t\tif (b.backupName && !live.has(b.backupName)) doomed.add(b.backupName);\n\t\t}\n\t}\n\tif (doomed.size === 0) return;\n\tawait Promise.allSettled(Array.from(doomed, (name) => unlink(backupPathFor(sid, name))));\n}\n\n// ---- rewind ---------------------------------------------------------------\n\n/**\n * Absolute paths that restoring to `target` would change on disk (empty =\n * nothing to do). Same walk applySnapshot performs, without writing — the\n * caller can both count and preview the files from one pass. Concurrent with\n * IO_CONCURRENCY; result order matches trackedFiles insertion order.\n */\nexport async function collectChanges(sid: string, target: FileHistorySnapshot): Promise<string[]> {\n\tconst state = getState(sid);\n\tconst cwd = cwdFor(sid);\n\tconst trackings = Array.from(state.trackedFiles);\n\tconst results = await mapPool(trackings, IO_CONCURRENCY, async (tracking) => {\n\t\ttry {\n\t\t\tconst filePath = expand(tracking, cwd);\n\t\t\tconst name = backupNameForTarget(state, target, tracking);\n\t\t\tif (name === undefined) return null;\n\t\t\tif (name === null) {\n\t\t\t\treturn (await statOrNull(filePath)) ? filePath : null;\n\t\t\t}\n\t\t\treturn (await originChanged(sid, filePath, name)) ? filePath : null;\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t});\n\treturn results.filter((p): p is string => p !== null);\n}\n\nexport interface CoarseDiffStats {\n\t/** Absolute paths that would change (same as collectChanges). */\n\tpaths: string[];\n\t/**\n\t * Coarse line insertions when restoring (lines present in backup, not in\n\t * current). Bag-of-lines, not a true Myers diff — confirm UI only.\n\t */\n\tinsertions: number;\n\t/** Coarse line deletions when restoring (lines present in current, not in backup). */\n\tdeletions: number;\n}\n\n/**\n * Coarse +N/−M line stats for a restore confirm dialog. Only loads text for\n * paths already known to differ (from collectChanges). Oversized files still\n * appear in `paths` but contribute 0 to line totals. No extra dependency.\n */\nexport async function collectChangeDiffStats(\n\tsid: string,\n\ttarget: FileHistorySnapshot,\n\tchangedPaths: readonly string[],\n): Promise<CoarseDiffStats> {\n\tif (changedPaths.length === 0) {\n\t\treturn { paths: [], insertions: 0, deletions: 0 };\n\t}\n\tconst state = getState(sid);\n\tconst cwd = cwdFor(sid);\n\t// Reverse map abs path → tracking key for the known changed set.\n\tconst trackingByAbs = new Map<string, string>();\n\tfor (const tracking of state.trackedFiles) {\n\t\ttrackingByAbs.set(expand(tracking, cwd), tracking);\n\t}\n\n\tconst results = await mapPool([...changedPaths], IO_CONCURRENCY, async (filePath) => {\n\t\ttry {\n\t\t\tconst tracking = trackingByAbs.get(filePath);\n\t\t\tif (!tracking) return { insertions: 0, deletions: 0 };\n\t\t\tconst name = backupNameForTarget(state, target, tracking);\n\t\t\tif (name === undefined) return { insertions: 0, deletions: 0 };\n\t\t\treturn await coarseLineDelta(sid, filePath, name);\n\t\t} catch {\n\t\t\treturn { insertions: 0, deletions: 0 };\n\t\t}\n\t});\n\n\tlet insertions = 0;\n\tlet deletions = 0;\n\tfor (const r of results) {\n\t\tinsertions += r.insertions;\n\t\tdeletions += r.deletions;\n\t}\n\treturn { paths: [...changedPaths], insertions, deletions };\n}\n\n/**\n * Bag-of-lines delta for current worktree → backup target (restore direction).\n * insertions = lines that appear after restore; deletions = lines removed.\n */\nasync function coarseLineDelta(\n\tsid: string,\n\tfilePath: string,\n\tbackupName: string | null,\n): Promise<{ insertions: number; deletions: number }> {\n\tconst currentText = await readTextCapped(filePath, MAX_DIFF_BYTES);\n\tif (backupName === null) {\n\t\t// Snapshot: file did not exist → restore deletes it.\n\t\tif (currentText === null) return { insertions: 0, deletions: 0 };\n\t\treturn { insertions: 0, deletions: countLines(currentText) };\n\t}\n\tconst backupText = await readTextCapped(backupPathFor(sid, backupName), MAX_DIFF_BYTES);\n\tif (currentText === null && backupText === null) return { insertions: 0, deletions: 0 };\n\tif (currentText === null) return { insertions: countLines(backupText ?? \"\"), deletions: 0 };\n\tif (backupText === null) return { insertions: 0, deletions: countLines(currentText) };\n\treturn bagOfLinesDelta(currentText, backupText);\n}\n\n/** null = missing or oversize/unreadable (skip line contrib). */\nasync function readTextCapped(filePath: string, maxBytes: number): Promise<string | null> {\n\ttry {\n\t\tconst st = await statOrNull(filePath);\n\t\tif (!st) return null;\n\t\tif (st.size > maxBytes) return null;\n\t\tconst buf = await readFile(filePath);\n\t\t// Skip obvious binary (NUL in the first 8K).\n\t\tconst sample = buf.subarray(0, Math.min(buf.length, 8192));\n\t\tif (sample.includes(0)) return null;\n\t\treturn buf.toString(\"utf8\");\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction countLines(text: string): number {\n\tif (text.length === 0) return 0;\n\tlet n = 1;\n\tfor (let i = 0; i < text.length; i++) {\n\t\tif (text.charCodeAt(i) === 10 /* \\n */) n++;\n\t}\n\t// Trailing newline does not add an extra empty \"line\" for display counts.\n\tif (text.endsWith(\"\\n\")) n--;\n\treturn Math.max(n, 0);\n}\n\n/**\n * Multiset frequency delta: from → to.\n * deletions = lines over-represented in `from`; insertions = over-represented in `to`.\n */\nfunction bagOfLinesDelta(from: string, to: string): { insertions: number; deletions: number } {\n\tconst fromCounts = lineFrequency(from);\n\tconst toCounts = lineFrequency(to);\n\tlet insertions = 0;\n\tlet deletions = 0;\n\tconst keys = new Set([...fromCounts.keys(), ...toCounts.keys()]);\n\tfor (const key of keys) {\n\t\tconst a = fromCounts.get(key) ?? 0;\n\t\tconst b = toCounts.get(key) ?? 0;\n\t\tif (b > a) insertions += b - a;\n\t\telse if (a > b) deletions += a - b;\n\t}\n\treturn { insertions, deletions };\n}\n\nfunction lineFrequency(text: string): Map<string, number> {\n\tconst map = new Map<string, number>();\n\t// Preserve empty file as zero lines; otherwise split including last empty.\n\tif (text.length === 0) return map;\n\tconst lines = text.split(\"\\n\");\n\tif (lines.length > 0 && lines[lines.length - 1] === \"\") lines.pop();\n\tfor (const line of lines) {\n\t\tmap.set(line, (map.get(line) ?? 0) + 1);\n\t}\n\treturn map;\n}\n\n/**\n * Restore the work tree to `target`. Returns the list of changed file paths.\n * Pass `onlyPaths` (from a prior collectChanges) to skip a second originChanged\n * pass and only touch those absolute paths.\n */\nexport async function applySnapshot(\n\tsid: string,\n\ttarget: FileHistorySnapshot,\n\topts?: ApplySnapshotOptions,\n): Promise<string[]> {\n\tconst state = getState(sid);\n\tconst cwd = cwdFor(sid);\n\tconst only = opts?.onlyPaths;\n\tconst trackings = Array.from(state.trackedFiles).filter((tracking) => {\n\t\tif (!only) return true;\n\t\treturn only.has(expand(tracking, cwd));\n\t});\n\n\tconst results = await mapPool(trackings, IO_CONCURRENCY, async (tracking) => {\n\t\ttry {\n\t\t\tconst filePath = expand(tracking, cwd);\n\t\t\tconst name = backupNameForTarget(state, target, tracking);\n\t\t\tif (name === undefined) return null; // can't resolve -> leave file alone\n\t\t\tif (name === null) {\n\t\t\t\ttry {\n\t\t\t\t\tawait unlink(filePath);\n\t\t\t\t\t// Worktree no longer matches any recorded identity.\n\t\t\t\t\tstate.lastSeen.delete(tracking);\n\t\t\t\t\treturn filePath;\n\t\t\t\t} catch (e) {\n\t\t\t\t\tif (!isENOENT(e)) throw e;\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\t\t\t}\n\t\t\t// onlyPaths already proven different at confirm time — skip re-compare.\n\t\t\tif (only || (await originChanged(sid, filePath, name))) {\n\t\t\t\tawait restoreBackup(sid, filePath, name);\n\t\t\t\t// Drop stale lastSeen: worktree now matches the target blob, not the\n\t\t\t\t// latest backup identity beginTurn compares against.\n\t\t\t\tstate.lastSeen.delete(tracking);\n\t\t\t\treturn filePath;\n\t\t\t}\n\t\t\treturn null;\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t});\n\treturn results.filter((p): p is string => p !== null);\n}\n\n// ---- persistence rebuild + resume migration -------------------------------\n\n/** Rebuild in-memory state from snapshots persisted in the session JSONL. */\nexport function restoreStateFromSnapshots(\n\tsid: string,\n\tcwd: string,\n\tsnapshots: FileHistorySnapshot[],\n\tmaxSnapshots = MAX_SNAPSHOTS,\n): void {\n\tcwds.set(sid, cwd);\n\t// Apply the same cap on reload so a long session's JSONL can't reinflate the\n\t// in-memory ring past the limit. trackedFiles is rebuilt from the retained\n\t// frames only (older frames are unreachable for rewind anyway); blobs only\n\t// those frames referenced are pruned best-effort, mirroring endTurn.\n\tconst retained = snapshots.length > maxSnapshots ? snapshots.slice(-maxSnapshots) : snapshots;\n\tif (retained.length < snapshots.length) {\n\t\tvoid pruneDroppedBlobs(sid, snapshots.slice(0, snapshots.length - retained.length), retained);\n\t}\n\tconst trackedFiles = new Set<string>();\n\tfor (const snap of retained) {\n\t\tfor (const key of Object.keys(snap.trackedFileBackups)) trackedFiles.add(key);\n\t}\n\tstates.set(sid, {\n\t\tsnapshots: [...retained],\n\t\ttrackedFiles,\n\t\tlatestByTracking: rebuildLatestIndex(retained),\n\t\t// lastSeen is process-local; after reload we re-establish it on the first\n\t\t// beginTurn via originChanged / createBackup.\n\t\tlastSeen: new Map(),\n\t\tpending: null,\n\t\tdirty: false,\n\t\tseq: retained.length,\n\t});\n}\n\n/**\n * Hard-link this session's backup blobs from a previous session's directory\n * (resume/fork carries the conversation + its snapshot index, but the blobs live\n * under the old session id). Falls back to copy. No-op when ids match.\n */\nexport async function migrateBackupsFromSession(\n\tprevSid: string,\n\tsid: string,\n\tsnapshots: FileHistorySnapshot[],\n): Promise<void> {\n\tif (!prevSid || prevSid === sid) return;\n\tconst destDir = backupsDir(sid);\n\tawait mkdir(destDir, { recursive: true });\n\tawait Promise.allSettled(\n\t\tsnapshots.flatMap((snap) =>\n\t\t\tObject.values(snap.trackedFileBackups)\n\t\t\t\t.filter((b): b is FileBackup & { backupName: string } => b.backupName !== null)\n\t\t\t\t.map(async ({ backupName: name }) => {\n\t\t\t\t\tconst from = join(backupsDir(prevSid), name);\n\t\t\t\t\tconst to = join(destDir, name);\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait link(from, to);\n\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\tconst code = (e as { code?: string }).code;\n\t\t\t\t\t\tif (code === \"EEXIST\") return;\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait copyFile(from, to);\n\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t// best-effort; a missing blob just means that version can't restore\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}),\n\t\t),\n\t);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../../src/extensions/rewind/engine.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * engine.ts — file-history backup engine for the rewind extension.\n *\n * Ported from Claude Code 2.1.88's src/utils/fileHistory.ts, adapted from its\n * React state-updater to module-level per-session maps. The core idea (and the\n * reason this exists): we back up ONLY the files Pi's edit/write tools are about\n * to modify — one copyFile before the edit — instead of snapshotting the whole\n * work tree. Cost is proportional to \"how many files Pi changed\", not project\n * size, so it never blocks the session-lifecycle critical path and storage stays\n * tiny.\n *\n * Per turn:\n * - beginTurn() (before_agent_start): open a working frame and re-record every\n * already-tracked file at its current (turn-start) state, reusing the latest\n * backup when unchanged (worktree lastSeen → mtime/size/content), creating a\n * new version when not. lastSeen skips backup-blob IO when the worktree has\n * not moved since the last confirm; content compare streams in 64 KiB chunks.\n * - trackEdit() (tool_call edit|write, before the write): back up a *newly*\n * edited file at its pre-edit state into the working frame (null marker when\n * the target does not exist yet, so rewind deletes the created file).\n * - endTurn() (agent_settled): if anything changed AND the caller resolved a\n * turn anchor, stamp + return the frame to persist; else discard it (an\n * unanchored frame could never be matched by /tree and would exist only in\n * memory). agent_settled (not agent_end) so auto-retry / overflow\n * compact-retry / queued follow-ups share one logical turn.\n *\n * Rewind = applySnapshot(): restore every tracked file to the version recorded in\n * the target frame (copy back / delete for null), touching only files that differ.\n *\n * Backups live at <rewindBackupsDir(sessionId)>/<sha256(relpath)[:16]>@v<n>.\n */\nimport { createHash } from \"node:crypto\";\nimport { chmodSync, copyFileSync, mkdirSync, type Stats, statSync } from \"node:fs\";\nimport { chmod, copyFile, link, mkdir, open, readFile, stat, unlink } from \"node:fs/promises\";\nimport { dirname, isAbsolute, join, relative } from \"node:path\";\nimport type { FileBackup, FileHistorySnapshot } from \"./snapshot.ts\";\nimport { backupsDir } from \"./storage.ts\";\n\n/** Cap on retained snapshots per session (matches CC's MAX_SNAPSHOTS). */\nconst MAX_SNAPSHOTS = 100;\n/** Avoid reading very large files solely to compare backup content. */\nconst MAX_CONTENT_BYTES = 25 * 1024 * 1024;\n/** Cap concurrent backup/compare/restore IO so long tracked sets don't thrash handles. */\nconst IO_CONCURRENCY = 16;\n/** Cap bytes loaded for coarse restore line stats (confirm dialog only). */\nconst MAX_DIFF_BYTES = 1_048_576;\n/** Streaming compare chunk size (avoids buffering whole files up to MAX_CONTENT_BYTES). */\nconst COMPARE_CHUNK_BYTES = 64 * 1024;\n\n/** Worktree identity recorded when we last backed up / confirmed a tracking path. */\ninterface SeenStats {\n\tmtimeMs: number;\n\tsize: number;\n\tmode: number;\n}\n\nfunction seenFromStats(st: Stats): SeenStats {\n\treturn { mtimeMs: st.mtimeMs, size: st.size, mode: st.mode };\n}\n\nfunction sameSeen(a: SeenStats, b: Stats): boolean {\n\treturn a.mtimeMs === b.mtimeMs && a.size === b.size && a.mode === b.mode;\n}\n\nexport interface FileHistoryState {\n\t/** Finalized + persisted frames, oldest first. */\n\tsnapshots: FileHistorySnapshot[];\n\t/** All tracking-paths ever edited this session. */\n\ttrackedFiles: Set<string>;\n\t/** Latest backup per tracking path (finalized + in-progress pending versions). */\n\tlatestByTracking: Map<string, FileBackup>;\n\t/**\n\t * Worktree mtime/size/mode at the last backup or unchanged confirmation.\n\t * beginTurn uses this to skip backup-blob stats/content when the worktree\n\t * has not moved since we last recorded it (still stats the worktree once).\n\t */\n\tlastSeen: Map<string, SeenStats>;\n\t/** The current turn's working frame (built across the turn, persisted at endTurn). */\n\tpending: FileHistorySnapshot | null;\n\t/** Whether the pending frame differs from the last finalized frame. */\n\tdirty: boolean;\n\t/** Monotonic activity counter (incremented on every finalized frame). */\n\tseq: number;\n}\n\nexport interface ApplySnapshotOptions {\n\t/**\n\t * Absolute paths already known to differ (e.g. from collectChanges).\n\t * When set, only those paths are restored/deleted and originChanged is skipped.\n\t */\n\tonlyPaths?: ReadonlySet<string>;\n}\n\n// ---- per-session state ----------------------------------------------------\n\nconst states = new Map<string, FileHistoryState>();\nconst cwds = new Map<string, string>();\n\nfunction freshState(): FileHistoryState {\n\treturn {\n\t\tsnapshots: [],\n\t\ttrackedFiles: new Set(),\n\t\tlatestByTracking: new Map(),\n\t\tlastSeen: new Map(),\n\t\tpending: null,\n\t\tdirty: false,\n\t\tseq: 0,\n\t};\n}\n\n/** Run async work over items with a fixed concurrency cap. Preserves result order. */\nasync function mapPool<T, R>(items: readonly T[], concurrency: number, fn: (item: T) => Promise<R>): Promise<R[]> {\n\tif (items.length === 0) return [];\n\tconst results = new Array<R>(items.length);\n\tlet next = 0;\n\tasync function worker(): Promise<void> {\n\t\tfor (;;) {\n\t\t\tconst i = next++;\n\t\t\tif (i >= items.length) return;\n\t\t\tresults[i] = await fn(items[i]!);\n\t\t}\n\t}\n\tconst n = Math.min(concurrency, items.length);\n\tawait Promise.all(Array.from({ length: n }, () => worker()));\n\treturn results;\n}\n\n/** Rebuild latestByTracking from finalized frames (oldest → newest, last write wins). */\nfunction rebuildLatestIndex(snapshots: FileHistorySnapshot[]): Map<string, FileBackup> {\n\tconst latest = new Map<string, FileBackup>();\n\tfor (const snap of snapshots) {\n\t\tfor (const [tracking, backup] of Object.entries(snap.trackedFileBackups)) {\n\t\t\tlatest.set(tracking, backup);\n\t\t}\n\t}\n\treturn latest;\n}\n\nfunction getState(sid: string): FileHistoryState {\n\tlet s = states.get(sid);\n\tif (!s) {\n\t\ts = freshState();\n\t\tstates.set(sid, s);\n\t}\n\treturn s;\n}\n\n/** Bind a session id to its cwd (for relative-path keying). Call at session_start. */\nexport function registerSession(sid: string, cwd: string): void {\n\tcwds.set(sid, cwd);\n\tgetState(sid);\n}\n\nexport function disposeSession(sid: string): void {\n\tstates.delete(sid);\n\tcwds.delete(sid);\n}\n\nexport function getSnapshots(sid: string): FileHistorySnapshot[] {\n\treturn states.get(sid)?.snapshots ?? [];\n}\n\nfunction cwdFor(sid: string): string {\n\treturn cwds.get(sid) ?? process.cwd();\n}\n\n// ---- path helpers ---------------------------------------------------------\n\n/** Use the cwd-relative path as the tracking key when inside cwd (shorter, portable). */\nfunction shorten(absPath: string, cwd: string): string {\n\tif (!isAbsolute(absPath)) return absPath;\n\tif (absPath === cwd) return absPath;\n\tconst rel = relative(cwd, absPath);\n\tif (!rel || rel.startsWith(\"..\") || isAbsolute(rel)) return absPath;\n\treturn rel;\n}\n\nfunction expand(tracking: string, cwd: string): string {\n\treturn isAbsolute(tracking) ? tracking : join(cwd, tracking);\n}\n\nfunction backupName(tracking: string, version: number): string {\n\tconst h = createHash(\"sha256\").update(tracking).digest(\"hex\").slice(0, 16);\n\treturn `${h}@v${version}`;\n}\n\nfunction backupPathFor(sid: string, name: string): string {\n\treturn join(backupsDir(sid), name);\n}\n\nfunction latestBackupOf(state: FileHistoryState, tracking: string): FileBackup | undefined {\n\treturn state.latestByTracking.get(tracking);\n}\n\n/** First-ever recorded backup for a file (used when rewinding before it was tracked). */\nfunction firstBackupName(state: FileHistoryState, tracking: string): string | null | undefined {\n\tfor (const snap of state.snapshots) {\n\t\tconst b = snap.trackedFileBackups[tracking];\n\t\tif (b && b.version === 1) return b.backupName;\n\t}\n\treturn undefined;\n}\n\n/** Resolve the backup blob name for `tracking` at `target` (null = did not exist). */\nfunction backupNameForTarget(\n\tstate: FileHistoryState,\n\ttarget: FileHistorySnapshot,\n\ttracking: string,\n): string | null | undefined {\n\tconst tb = target.trackedFileBackups[tracking];\n\tif (tb) return tb.backupName;\n\treturn firstBackupName(state, tracking);\n}\n\n// ---- change detection (ported fileHistory.ts compareStatsAndContent;\n// content path streams via filesEqualChunked instead of full-buffer reads)\n\nfunction isENOENT(e: unknown): boolean {\n\treturn !!e && typeof e === \"object\" && (e as { code?: string }).code === \"ENOENT\";\n}\n\nasync function statOrNull(p: string): Promise<Stats | null> {\n\ttry {\n\t\treturn await stat(p);\n\t} catch (e) {\n\t\tif (isENOENT(e)) return null;\n\t\tthrow e;\n\t}\n}\n\n/**\n * Raw-byte equality without buffering whole files. A utf-8 decode maps invalid\n * sequences to U+FFFD, which can make two DIFFERENT binary files compare equal —\n * and then rewind would silently skip both the re-backup and the restore.\n */\nasync function filesEqualChunked(aPath: string, bPath: string, size: number): Promise<boolean> {\n\tif (size === 0) return true;\n\tconst [aHandle, bHandle] = await Promise.all([open(aPath, \"r\"), open(bPath, \"r\")]);\n\ttry {\n\t\tconst aBuf = Buffer.allocUnsafe(COMPARE_CHUNK_BYTES);\n\t\tconst bBuf = Buffer.allocUnsafe(COMPARE_CHUNK_BYTES);\n\t\tlet remaining = size;\n\t\twhile (remaining > 0) {\n\t\t\tconst want = Math.min(COMPARE_CHUNK_BYTES, remaining);\n\t\t\tconst [aRead, bRead] = await Promise.all([\n\t\t\t\taHandle.read(aBuf, 0, want, null),\n\t\t\t\tbHandle.read(bBuf, 0, want, null),\n\t\t\t]);\n\t\t\tif (aRead.bytesRead !== bRead.bytesRead) return false;\n\t\t\tif (aRead.bytesRead === 0) return remaining === 0;\n\t\t\tif (!aBuf.subarray(0, aRead.bytesRead).equals(bBuf.subarray(0, bRead.bytesRead))) return false;\n\t\t\tremaining -= aRead.bytesRead;\n\t\t}\n\t\treturn true;\n\t} finally {\n\t\tawait Promise.allSettled([aHandle.close(), bHandle.close()]);\n\t}\n}\n\n/** True when the on-disk file differs from its backup blob. */\nasync function originChanged(\n\tsid: string,\n\tfilePath: string,\n\tname: string,\n\topts?: { hint?: Stats; trustMtime?: boolean },\n): Promise<boolean> {\n\tconst backupPath = backupPathFor(sid, name);\n\tconst orig = opts?.hint ?? (await statOrNull(filePath).catch(() => null));\n\tconst back = await statOrNull(backupPath).catch(() => null);\n\n\t// One exists, one missing -> changed.\n\tif ((orig === null) !== (back === null)) return true;\n\tif (orig === null || back === null) return false;\n\tif (orig.mode !== back.mode || orig.size !== back.size) return true;\n\t// Original untouched since the backup was written -> unchanged (skip content\n\t// read). Trusted only on the beginTurn path (worst case there: one redundant\n\t// version). Restore-side change detection must not be fooled by a same-size\n\t// content swap that carries an older mtime (archive extraction, touch -d).\n\tif (opts?.trustMtime && orig.mtimeMs < back.mtimeMs) return false;\n\t// Memory guard: don't load huge files just to byte-compare; assume changed and\n\t// let createBackup stream a new version instead (copyFile never buffers whole files).\n\tif (orig.size > MAX_CONTENT_BYTES) return true;\n\ttry {\n\t\treturn !(await filesEqualChunked(filePath, backupPath, orig.size));\n\t} catch {\n\t\treturn true;\n\t}\n}\n\n// ---- backup / restore IO --------------------------------------------------\n\n/** Copy the file's current contents into a backup blob; null backup when absent. */\nasync function createBackup(\n\tsid: string,\n\tfilePath: string,\n\ttracking: string,\n\tversion: number,\n\tknown?: Stats | null,\n): Promise<FileBackup> {\n\tlet src: Stats;\n\tif (known === null) return { backupName: null, version };\n\tif (known) {\n\t\tsrc = known;\n\t} else {\n\t\ttry {\n\t\t\tsrc = await stat(filePath);\n\t\t} catch (e) {\n\t\t\tif (isENOENT(e)) return { backupName: null, version };\n\t\t\tthrow e;\n\t\t}\n\t}\n\tconst name = backupName(tracking, version);\n\tconst dest = backupPathFor(sid, name);\n\ttry {\n\t\tawait copyFile(filePath, dest);\n\t} catch (e) {\n\t\tif (!isENOENT(e)) throw e;\n\t\tawait mkdir(dirname(dest), { recursive: true });\n\t\tawait copyFile(filePath, dest);\n\t}\n\ttry {\n\t\tawait chmod(dest, src.mode);\n\t} catch {\n\t\t// best-effort (e.g. Windows); content is what matters\n\t}\n\treturn { backupName: name, version };\n}\n\n/**\n * Synchronous backup, used on the edit critical path (tool_call). Doing the copy\n * synchronously guarantees it completes before the hook returns control to the\n * host — so the backup always captures the file's PRE-edit contents, regardless\n * of whether the host awaits the hook before running the tool.\n *\n * `known` reuses a pre-stat from the caller (`null` = known ENOENT) so trackEdit\n * can record lastSeen without a second statSync on the critical path.\n */\nfunction createBackupSync(\n\tsid: string,\n\tfilePath: string,\n\ttracking: string,\n\tversion: number,\n\tknown?: Stats | null,\n): FileBackup {\n\tlet src: Stats;\n\tif (known === null) return { backupName: null, version };\n\tif (known) {\n\t\tsrc = known;\n\t} else {\n\t\ttry {\n\t\t\tsrc = statSync(filePath);\n\t\t} catch (e) {\n\t\t\tif (isENOENT(e)) return { backupName: null, version };\n\t\t\tthrow e;\n\t\t}\n\t}\n\tconst name = backupName(tracking, version);\n\tconst dest = backupPathFor(sid, name);\n\ttry {\n\t\tcopyFileSync(filePath, dest);\n\t} catch (e) {\n\t\tif (!isENOENT(e)) throw e;\n\t\tmkdirSync(dirname(dest), { recursive: true });\n\t\tcopyFileSync(filePath, dest);\n\t}\n\ttry {\n\t\tchmodSync(dest, src.mode);\n\t} catch {\n\t\t// best-effort\n\t}\n\treturn { backupName: name, version };\n}\n\nasync function restoreBackup(sid: string, filePath: string, name: string): Promise<void> {\n\tconst backupPath = backupPathFor(sid, name);\n\tlet back: Stats;\n\ttry {\n\t\tback = await stat(backupPath);\n\t} catch {\n\t\treturn; // backup vanished; leave the file untouched\n\t}\n\ttry {\n\t\tawait copyFile(backupPath, filePath);\n\t} catch (e) {\n\t\tif (!isENOENT(e)) throw e;\n\t\tawait mkdir(dirname(filePath), { recursive: true });\n\t\tawait copyFile(backupPath, filePath);\n\t}\n\ttry {\n\t\tawait chmod(filePath, back.mode);\n\t} catch {\n\t\t// best-effort\n\t}\n}\n\n// ---- per-turn lifecycle ---------------------------------------------------\n\n/**\n * Open the working frame for a turn and re-record every tracked file at its\n * current state (reuse latest backup when unchanged, new version when changed).\n * Sets state.dirty when any file produced a new version.\n */\nexport async function beginTurn(sid: string): Promise<void> {\n\tconst state = getState(sid);\n\tconst cwd = cwdFor(sid);\n\tconst backups: Record<string, FileBackup> = {};\n\tlet dirty = false;\n\n\tawait mapPool(Array.from(state.trackedFiles), IO_CONCURRENCY, async (tracking) => {\n\t\ttry {\n\t\t\tconst filePath = expand(tracking, cwd);\n\t\t\tconst latest = latestBackupOf(state, tracking);\n\t\t\tconst nextVersion = latest ? latest.version + 1 : 1;\n\t\t\tconst st = await statOrNull(filePath);\n\t\t\tif (!st) {\n\t\t\t\t// Already recorded absent in the latest frame -> reuse it. Allocating a\n\t\t\t\t// fresh null version every turn would pin dirty=true forever once a\n\t\t\t\t// tracked file is deleted, defeating the \"skip unchanged turns\" check\n\t\t\t\t// and flushing real checkpoints out of the capped ring.\n\t\t\t\tstate.lastSeen.delete(tracking);\n\t\t\t\tif (latest && latest.backupName === null) {\n\t\t\t\t\tbackups[tracking] = latest;\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst absent: FileBackup = { backupName: null, version: nextVersion };\n\t\t\t\tbackups[tracking] = absent;\n\t\t\t\tstate.latestByTracking.set(tracking, absent);\n\t\t\t\tdirty = true;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// Worktree identity unchanged since last backup/confirm → reuse without\n\t\t\t// opening the backup blob (still paid one worktree stat above).\n\t\t\tconst seen = state.lastSeen.get(tracking);\n\t\t\tif (latest && latest.backupName !== null && seen && sameSeen(seen, st)) {\n\t\t\t\tbackups[tracking] = latest;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (\n\t\t\t\tlatest &&\n\t\t\t\tlatest.backupName !== null &&\n\t\t\t\t!(await originChanged(sid, filePath, latest.backupName, { hint: st, trustMtime: true }))\n\t\t\t) {\n\t\t\t\tbackups[tracking] = latest; // unchanged -> reuse\n\t\t\t\tstate.lastSeen.set(tracking, seenFromStats(st));\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst created = await createBackup(sid, filePath, tracking, nextVersion, st);\n\t\t\tbackups[tracking] = created;\n\t\t\tstate.latestByTracking.set(tracking, created);\n\t\t\t// Record the pre-backup worktree identity (copyFile does not change it).\n\t\t\tstate.lastSeen.set(tracking, seenFromStats(st));\n\t\t\tdirty = true;\n\t\t} catch {\n\t\t\t// skip this file; never break the turn\n\t\t}\n\t});\n\n\tstate.pending = { v: 1, userEntryId: \"\", turnId: \"\", prompt: \"\", trackedFileBackups: backups, timestamp: \"\" };\n\tstate.dirty = dirty;\n}\n\n/**\n * Back up a file about to be edited/written, if not already captured this turn.\n * Call from the tool_call hook BEFORE the edit lands. ENOENT target -> null\n * marker (rewind deletes the created file). Synchronous so the backup is on disk\n * before the hook returns (see createBackupSync).\n */\n/**\n * Reuse an existing tracking key that differs only by case (Windows paths are\n * case-insensitive): two keys for one physical file would race each other in\n * applySnapshot's concurrent restore.\n */\nfunction canonicalTracking(state: FileHistoryState, tracking: string): string {\n\tif (process.platform !== \"win32\" || state.trackedFiles.has(tracking)) return tracking;\n\tconst lower = tracking.toLowerCase();\n\tfor (const existing of state.trackedFiles) {\n\t\tif (existing.toLowerCase() === lower) return existing;\n\t}\n\treturn tracking;\n}\n\nexport function trackEdit(sid: string, absPath: string): void {\n\tconst state = getState(sid);\n\tconst cwd = cwdFor(sid);\n\tconst tracking = canonicalTracking(state, shorten(absPath, cwd));\n\n\tif (!state.pending) {\n\t\tstate.pending = { v: 1, userEntryId: \"\", turnId: \"\", prompt: \"\", trackedFileBackups: {}, timestamp: \"\" };\n\t}\n\tif (state.pending.trackedFileBackups[tracking]) return; // already captured this turn\n\n\tconst latest = latestBackupOf(state, tracking);\n\tconst version = latest ? latest.version + 1 : 1;\n\tconst filePath = expand(tracking, cwd);\n\t// One pre-edit stat: drives the backup and lastSeen (null = new-file marker).\n\tlet preEdit: Stats | null = null;\n\ttry {\n\t\tpreEdit = statSync(filePath);\n\t} catch (e) {\n\t\tif (!isENOENT(e)) throw e;\n\t}\n\tconst backup = createBackupSync(sid, filePath, tracking, version, preEdit);\n\tstate.pending.trackedFileBackups[tracking] = backup;\n\tstate.trackedFiles.add(tracking);\n\tstate.latestByTracking.set(tracking, backup);\n\tif (preEdit) state.lastSeen.set(tracking, seenFromStats(preEdit));\n\telse state.lastSeen.delete(tracking);\n\tstate.dirty = true;\n}\n\n/**\n * Finalize the turn. Returns the snapshot to persist (caller appendEntry's it),\n * or null when nothing changed this turn. Pushes finalized frames into the\n * in-memory ring (capped at MAX_SNAPSHOTS).\n */\nexport function endTurn(\n\tsid: string,\n\tuserEntryId: string,\n\tturnId: string,\n\tprompt: string,\n\ttimestamp: string,\n\tmaxSnapshots = MAX_SNAPSHOTS,\n): FileHistorySnapshot | null {\n\tconst state = getState(sid);\n\tconst pending = state.pending;\n\tstate.pending = null;\n\tif (!pending || !state.dirty) {\n\t\tstate.dirty = false;\n\t\treturn null;\n\t}\n\tstate.dirty = false;\n\t// No resolvable anchor (e.g. a custom-triggered run that appended no user\n\t// entry): discard the frame. Pushing it into the ring without persisting it\n\t// would diverge memory from the JSONL, and /tree could never match it anyway.\n\tif (!userEntryId) return null;\n\tconst frame: FileHistorySnapshot = { ...pending, userEntryId, turnId, prompt, timestamp };\n\tstate.snapshots.push(frame);\n\tif (state.snapshots.length > maxSnapshots) {\n\t\tconst dropped = state.snapshots.slice(0, state.snapshots.length - maxSnapshots);\n\t\tstate.snapshots = state.snapshots.slice(-maxSnapshots);\n\t\tvoid pruneDroppedBlobs(sid, dropped, state.snapshots);\n\t}\n\tstate.seq++;\n\treturn frame;\n}\n\n/**\n * Best-effort deletion of backup blobs referenced ONLY by frames dropped from\n * the capped ring. Backups are reused across frames (an unchanged file keeps\n * pointing at the same version), so a blob is unlinked only when no retained\n * frame still references it. Without this, the cap trims the in-memory index\n * while the blob files accumulate for the whole session — gc.ts only reclaims\n * whole session directories.\n */\nasync function pruneDroppedBlobs(\n\tsid: string,\n\tdropped: FileHistorySnapshot[],\n\tretained: FileHistorySnapshot[],\n): Promise<void> {\n\tconst live = new Set<string>();\n\tfor (const snap of retained) {\n\t\tfor (const b of Object.values(snap.trackedFileBackups)) {\n\t\t\tif (b.backupName) live.add(b.backupName);\n\t\t}\n\t}\n\tconst doomed = new Set<string>();\n\tfor (const snap of dropped) {\n\t\tfor (const b of Object.values(snap.trackedFileBackups)) {\n\t\t\tif (b.backupName && !live.has(b.backupName)) doomed.add(b.backupName);\n\t\t}\n\t}\n\tif (doomed.size === 0) return;\n\tawait Promise.allSettled(Array.from(doomed, (name) => unlink(backupPathFor(sid, name))));\n}\n\n// ---- rewind ---------------------------------------------------------------\n\n/**\n * Absolute paths that restoring to `target` would change on disk (empty =\n * nothing to do). Same walk applySnapshot performs, without writing — the\n * caller can both count and preview the files from one pass. Concurrent with\n * IO_CONCURRENCY; result order matches trackedFiles insertion order.\n */\nexport async function collectChanges(sid: string, target: FileHistorySnapshot): Promise<string[]> {\n\tconst state = getState(sid);\n\tconst cwd = cwdFor(sid);\n\tconst trackings = Array.from(state.trackedFiles);\n\tconst results = await mapPool(trackings, IO_CONCURRENCY, async (tracking) => {\n\t\ttry {\n\t\t\tconst filePath = expand(tracking, cwd);\n\t\t\tconst name = backupNameForTarget(state, target, tracking);\n\t\t\tif (name === undefined) return null;\n\t\t\tif (name === null) {\n\t\t\t\treturn (await statOrNull(filePath)) ? filePath : null;\n\t\t\t}\n\t\t\treturn (await originChanged(sid, filePath, name)) ? filePath : null;\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t});\n\treturn results.filter((p): p is string => p !== null);\n}\n\nexport interface CoarseDiffStats {\n\t/** Absolute paths that would change (same as collectChanges). */\n\tpaths: string[];\n\t/**\n\t * Coarse line insertions when restoring (lines present in backup, not in\n\t * current). Bag-of-lines, not a true Myers diff — confirm UI only.\n\t */\n\tinsertions: number;\n\t/** Coarse line deletions when restoring (lines present in current, not in backup). */\n\tdeletions: number;\n}\n\n/**\n * Coarse +N/−M line stats for a restore confirm dialog. Only loads text for\n * paths already known to differ (from collectChanges). Oversized files still\n * appear in `paths` but contribute 0 to line totals. No extra dependency.\n */\nexport async function collectChangeDiffStats(\n\tsid: string,\n\ttarget: FileHistorySnapshot,\n\tchangedPaths: readonly string[],\n): Promise<CoarseDiffStats> {\n\tif (changedPaths.length === 0) {\n\t\treturn { paths: [], insertions: 0, deletions: 0 };\n\t}\n\tconst state = getState(sid);\n\tconst cwd = cwdFor(sid);\n\t// Reverse map abs path → tracking key for the known changed set.\n\tconst trackingByAbs = new Map<string, string>();\n\tfor (const tracking of state.trackedFiles) {\n\t\ttrackingByAbs.set(expand(tracking, cwd), tracking);\n\t}\n\n\tconst results = await mapPool([...changedPaths], IO_CONCURRENCY, async (filePath) => {\n\t\ttry {\n\t\t\tconst tracking = trackingByAbs.get(filePath);\n\t\t\tif (!tracking) return { insertions: 0, deletions: 0 };\n\t\t\tconst name = backupNameForTarget(state, target, tracking);\n\t\t\tif (name === undefined) return { insertions: 0, deletions: 0 };\n\t\t\treturn await coarseLineDelta(sid, filePath, name);\n\t\t} catch {\n\t\t\treturn { insertions: 0, deletions: 0 };\n\t\t}\n\t});\n\n\tlet insertions = 0;\n\tlet deletions = 0;\n\tfor (const r of results) {\n\t\tinsertions += r.insertions;\n\t\tdeletions += r.deletions;\n\t}\n\treturn { paths: [...changedPaths], insertions, deletions };\n}\n\n/**\n * Bag-of-lines delta for current worktree → backup target (restore direction).\n * insertions = lines that appear after restore; deletions = lines removed.\n */\nasync function coarseLineDelta(\n\tsid: string,\n\tfilePath: string,\n\tbackupName: string | null,\n): Promise<{ insertions: number; deletions: number }> {\n\tconst currentText = await readTextCapped(filePath, MAX_DIFF_BYTES);\n\tif (backupName === null) {\n\t\t// Snapshot: file did not exist → restore deletes it.\n\t\tif (currentText === null) return { insertions: 0, deletions: 0 };\n\t\treturn { insertions: 0, deletions: countLines(currentText) };\n\t}\n\tconst backupText = await readTextCapped(backupPathFor(sid, backupName), MAX_DIFF_BYTES);\n\tif (currentText === null && backupText === null) return { insertions: 0, deletions: 0 };\n\tif (currentText === null) return { insertions: countLines(backupText ?? \"\"), deletions: 0 };\n\tif (backupText === null) return { insertions: 0, deletions: countLines(currentText) };\n\treturn bagOfLinesDelta(currentText, backupText);\n}\n\n/** null = missing or oversize/unreadable (skip line contrib). */\nasync function readTextCapped(filePath: string, maxBytes: number): Promise<string | null> {\n\ttry {\n\t\tconst st = await statOrNull(filePath);\n\t\tif (!st) return null;\n\t\tif (st.size > maxBytes) return null;\n\t\tconst buf = await readFile(filePath);\n\t\t// Skip obvious binary (NUL in the first 8K).\n\t\tconst sample = buf.subarray(0, Math.min(buf.length, 8192));\n\t\tif (sample.includes(0)) return null;\n\t\treturn buf.toString(\"utf8\");\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nfunction countLines(text: string): number {\n\tif (text.length === 0) return 0;\n\tlet n = 1;\n\tfor (let i = 0; i < text.length; i++) {\n\t\tif (text.charCodeAt(i) === 10 /* \\n */) n++;\n\t}\n\t// Trailing newline does not add an extra empty \"line\" for display counts.\n\tif (text.endsWith(\"\\n\")) n--;\n\treturn Math.max(n, 0);\n}\n\n/**\n * Multiset frequency delta: from → to.\n * deletions = lines over-represented in `from`; insertions = over-represented in `to`.\n */\nfunction bagOfLinesDelta(from: string, to: string): { insertions: number; deletions: number } {\n\tconst fromCounts = lineFrequency(from);\n\tconst toCounts = lineFrequency(to);\n\tlet insertions = 0;\n\tlet deletions = 0;\n\tconst keys = new Set([...fromCounts.keys(), ...toCounts.keys()]);\n\tfor (const key of keys) {\n\t\tconst a = fromCounts.get(key) ?? 0;\n\t\tconst b = toCounts.get(key) ?? 0;\n\t\tif (b > a) insertions += b - a;\n\t\telse if (a > b) deletions += a - b;\n\t}\n\treturn { insertions, deletions };\n}\n\nfunction lineFrequency(text: string): Map<string, number> {\n\tconst map = new Map<string, number>();\n\t// Preserve empty file as zero lines; otherwise split including last empty.\n\tif (text.length === 0) return map;\n\tconst lines = text.split(\"\\n\");\n\tif (lines.length > 0 && lines[lines.length - 1] === \"\") lines.pop();\n\tfor (const line of lines) {\n\t\tmap.set(line, (map.get(line) ?? 0) + 1);\n\t}\n\treturn map;\n}\n\n/**\n * Restore the work tree to `target`. Returns the list of changed file paths.\n * Pass `onlyPaths` (from a prior collectChanges) to skip a second originChanged\n * pass and only touch those absolute paths.\n */\nexport async function applySnapshot(\n\tsid: string,\n\ttarget: FileHistorySnapshot,\n\topts?: ApplySnapshotOptions,\n): Promise<string[]> {\n\tconst state = getState(sid);\n\tconst cwd = cwdFor(sid);\n\tconst only = opts?.onlyPaths;\n\tconst trackings = Array.from(state.trackedFiles).filter((tracking) => {\n\t\tif (!only) return true;\n\t\treturn only.has(expand(tracking, cwd));\n\t});\n\n\tconst results = await mapPool(trackings, IO_CONCURRENCY, async (tracking) => {\n\t\ttry {\n\t\t\tconst filePath = expand(tracking, cwd);\n\t\t\tconst name = backupNameForTarget(state, target, tracking);\n\t\t\tif (name === undefined) return null; // can't resolve -> leave file alone\n\t\t\tif (name === null) {\n\t\t\t\ttry {\n\t\t\t\t\tawait unlink(filePath);\n\t\t\t\t\t// Worktree no longer matches any recorded identity.\n\t\t\t\t\tstate.lastSeen.delete(tracking);\n\t\t\t\t\treturn filePath;\n\t\t\t\t} catch (e) {\n\t\t\t\t\tif (!isENOENT(e)) throw e;\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\t\t\t}\n\t\t\t// onlyPaths already proven different at confirm time — skip re-compare.\n\t\t\tif (only || (await originChanged(sid, filePath, name))) {\n\t\t\t\tawait restoreBackup(sid, filePath, name);\n\t\t\t\t// Drop stale lastSeen: worktree now matches the target blob, not the\n\t\t\t\t// latest backup identity beginTurn compares against.\n\t\t\t\tstate.lastSeen.delete(tracking);\n\t\t\t\treturn filePath;\n\t\t\t}\n\t\t\treturn null;\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t});\n\treturn results.filter((p): p is string => p !== null);\n}\n\n// ---- persistence rebuild + resume migration -------------------------------\n\n/** The trailing window of `snapshots` retained under the cap (endTurn's ring). */\nexport function capSnapshots(snapshots: FileHistorySnapshot[], maxSnapshots = MAX_SNAPSHOTS): FileHistorySnapshot[] {\n\treturn snapshots.length > maxSnapshots ? snapshots.slice(-maxSnapshots) : snapshots;\n}\n\n/** Rebuild in-memory state from snapshots persisted in the session JSONL. */\nexport function restoreStateFromSnapshots(\n\tsid: string,\n\tcwd: string,\n\tsnapshots: FileHistorySnapshot[],\n\tmaxSnapshots = MAX_SNAPSHOTS,\n): void {\n\tcwds.set(sid, cwd);\n\t// Apply the same cap on reload so a long session's JSONL can't reinflate the\n\t// in-memory ring past the limit. trackedFiles is rebuilt from the retained\n\t// frames only (older frames are unreachable for rewind anyway); blobs only\n\t// those frames referenced are pruned best-effort, mirroring endTurn.\n\tconst retained = capSnapshots(snapshots, maxSnapshots);\n\tif (retained.length < snapshots.length) {\n\t\tvoid pruneDroppedBlobs(sid, snapshots.slice(0, snapshots.length - retained.length), retained);\n\t}\n\tconst trackedFiles = new Set<string>();\n\tfor (const snap of retained) {\n\t\tfor (const key of Object.keys(snap.trackedFileBackups)) trackedFiles.add(key);\n\t}\n\tstates.set(sid, {\n\t\tsnapshots: [...retained],\n\t\ttrackedFiles,\n\t\tlatestByTracking: rebuildLatestIndex(retained),\n\t\t// lastSeen is process-local; after reload we re-establish it on the first\n\t\t// beginTurn via originChanged / createBackup.\n\t\tlastSeen: new Map(),\n\t\tpending: null,\n\t\tdirty: false,\n\t\tseq: retained.length,\n\t});\n}\n\n/**\n * Hard-link this session's backup blobs from a previous session's directory\n * (resume/fork carries the conversation + its snapshot index, but the blobs live\n * under the old session id). Falls back to copy. No-op when ids match.\n */\nexport async function migrateBackupsFromSession(\n\tprevSid: string,\n\tsid: string,\n\tsnapshots: FileHistorySnapshot[],\n): Promise<void> {\n\tif (!prevSid || prevSid === sid) return;\n\tconst destDir = backupsDir(sid);\n\tawait mkdir(destDir, { recursive: true });\n\tawait Promise.allSettled(\n\t\tsnapshots.flatMap((snap) =>\n\t\t\tObject.values(snap.trackedFileBackups)\n\t\t\t\t.filter((b): b is FileBackup & { backupName: string } => b.backupName !== null)\n\t\t\t\t.map(async ({ backupName: name }) => {\n\t\t\t\t\tconst from = join(backupsDir(prevSid), name);\n\t\t\t\t\tconst to = join(destDir, name);\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait link(from, to);\n\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\tconst code = (e as { code?: string }).code;\n\t\t\t\t\t\tif (code === \"EEXIST\") return;\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tawait copyFile(from, to);\n\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t// best-effort; a missing blob just means that version can't restore\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}),\n\t\t),\n\t);\n}\n"]}
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
* - trackEdit() (tool_call edit|write, before the write): back up a *newly*
|
|
19
19
|
* edited file at its pre-edit state into the working frame (null marker when
|
|
20
20
|
* the target does not exist yet, so rewind deletes the created file).
|
|
21
|
-
* - endTurn() (agent_settled): if anything changed
|
|
22
|
-
* to persist; else discard it
|
|
23
|
-
*
|
|
21
|
+
* - endTurn() (agent_settled): if anything changed AND the caller resolved a
|
|
22
|
+
* turn anchor, stamp + return the frame to persist; else discard it (an
|
|
23
|
+
* unanchored frame could never be matched by /tree and would exist only in
|
|
24
|
+
* memory). agent_settled (not agent_end) so auto-retry / overflow
|
|
25
|
+
* compact-retry / queued follow-ups share one logical turn.
|
|
24
26
|
*
|
|
25
27
|
* Rewind = applySnapshot(): restore every tracked file to the version recorded in
|
|
26
28
|
* the target frame (copy back / delete for null), touching only files that differ.
|
|
@@ -203,9 +205,9 @@ async function filesEqualChunked(aPath, bPath, size) {
|
|
|
203
205
|
}
|
|
204
206
|
}
|
|
205
207
|
/** True when the on-disk file differs from its backup blob. */
|
|
206
|
-
async function originChanged(sid, filePath, name,
|
|
208
|
+
async function originChanged(sid, filePath, name, opts) {
|
|
207
209
|
const backupPath = backupPathFor(sid, name);
|
|
208
|
-
const orig = hint ?? (await statOrNull(filePath).catch(() => null));
|
|
210
|
+
const orig = opts?.hint ?? (await statOrNull(filePath).catch(() => null));
|
|
209
211
|
const back = await statOrNull(backupPath).catch(() => null);
|
|
210
212
|
// One exists, one missing -> changed.
|
|
211
213
|
if ((orig === null) !== (back === null))
|
|
@@ -214,8 +216,11 @@ async function originChanged(sid, filePath, name, hint) {
|
|
|
214
216
|
return false;
|
|
215
217
|
if (orig.mode !== back.mode || orig.size !== back.size)
|
|
216
218
|
return true;
|
|
217
|
-
// Original untouched since the backup was written -> unchanged (skip content
|
|
218
|
-
|
|
219
|
+
// Original untouched since the backup was written -> unchanged (skip content
|
|
220
|
+
// read). Trusted only on the beginTurn path (worst case there: one redundant
|
|
221
|
+
// version). Restore-side change detection must not be fooled by a same-size
|
|
222
|
+
// content swap that carries an older mtime (archive extraction, touch -d).
|
|
223
|
+
if (opts?.trustMtime && orig.mtimeMs < back.mtimeMs)
|
|
219
224
|
return false;
|
|
220
225
|
// Memory guard: don't load huge files just to byte-compare; assume changed and
|
|
221
226
|
// let createBackup stream a new version instead (copyFile never buffers whole files).
|
|
@@ -376,7 +381,9 @@ export async function beginTurn(sid) {
|
|
|
376
381
|
backups[tracking] = latest;
|
|
377
382
|
return;
|
|
378
383
|
}
|
|
379
|
-
if (latest &&
|
|
384
|
+
if (latest &&
|
|
385
|
+
latest.backupName !== null &&
|
|
386
|
+
!(await originChanged(sid, filePath, latest.backupName, { hint: st, trustMtime: true }))) {
|
|
380
387
|
backups[tracking] = latest; // unchanged -> reuse
|
|
381
388
|
state.lastSeen.set(tracking, seenFromStats(st));
|
|
382
389
|
return;
|
|
@@ -401,10 +408,25 @@ export async function beginTurn(sid) {
|
|
|
401
408
|
* marker (rewind deletes the created file). Synchronous so the backup is on disk
|
|
402
409
|
* before the hook returns (see createBackupSync).
|
|
403
410
|
*/
|
|
411
|
+
/**
|
|
412
|
+
* Reuse an existing tracking key that differs only by case (Windows paths are
|
|
413
|
+
* case-insensitive): two keys for one physical file would race each other in
|
|
414
|
+
* applySnapshot's concurrent restore.
|
|
415
|
+
*/
|
|
416
|
+
function canonicalTracking(state, tracking) {
|
|
417
|
+
if (process.platform !== "win32" || state.trackedFiles.has(tracking))
|
|
418
|
+
return tracking;
|
|
419
|
+
const lower = tracking.toLowerCase();
|
|
420
|
+
for (const existing of state.trackedFiles) {
|
|
421
|
+
if (existing.toLowerCase() === lower)
|
|
422
|
+
return existing;
|
|
423
|
+
}
|
|
424
|
+
return tracking;
|
|
425
|
+
}
|
|
404
426
|
export function trackEdit(sid, absPath) {
|
|
405
427
|
const state = getState(sid);
|
|
406
428
|
const cwd = cwdFor(sid);
|
|
407
|
-
const tracking = shorten(absPath, cwd);
|
|
429
|
+
const tracking = canonicalTracking(state, shorten(absPath, cwd));
|
|
408
430
|
if (!state.pending) {
|
|
409
431
|
state.pending = { v: 1, userEntryId: "", turnId: "", prompt: "", trackedFileBackups: {}, timestamp: "" };
|
|
410
432
|
}
|
|
@@ -446,6 +468,11 @@ export function endTurn(sid, userEntryId, turnId, prompt, timestamp, maxSnapshot
|
|
|
446
468
|
return null;
|
|
447
469
|
}
|
|
448
470
|
state.dirty = false;
|
|
471
|
+
// No resolvable anchor (e.g. a custom-triggered run that appended no user
|
|
472
|
+
// entry): discard the frame. Pushing it into the ring without persisting it
|
|
473
|
+
// would diverge memory from the JSONL, and /tree could never match it anyway.
|
|
474
|
+
if (!userEntryId)
|
|
475
|
+
return null;
|
|
449
476
|
const frame = { ...pending, userEntryId, turnId, prompt, timestamp };
|
|
450
477
|
state.snapshots.push(frame);
|
|
451
478
|
if (state.snapshots.length > maxSnapshots) {
|
|
@@ -685,6 +712,10 @@ export async function applySnapshot(sid, target, opts) {
|
|
|
685
712
|
return results.filter((p) => p !== null);
|
|
686
713
|
}
|
|
687
714
|
// ---- persistence rebuild + resume migration -------------------------------
|
|
715
|
+
/** The trailing window of `snapshots` retained under the cap (endTurn's ring). */
|
|
716
|
+
export function capSnapshots(snapshots, maxSnapshots = MAX_SNAPSHOTS) {
|
|
717
|
+
return snapshots.length > maxSnapshots ? snapshots.slice(-maxSnapshots) : snapshots;
|
|
718
|
+
}
|
|
688
719
|
/** Rebuild in-memory state from snapshots persisted in the session JSONL. */
|
|
689
720
|
export function restoreStateFromSnapshots(sid, cwd, snapshots, maxSnapshots = MAX_SNAPSHOTS) {
|
|
690
721
|
cwds.set(sid, cwd);
|
|
@@ -692,7 +723,7 @@ export function restoreStateFromSnapshots(sid, cwd, snapshots, maxSnapshots = MA
|
|
|
692
723
|
// in-memory ring past the limit. trackedFiles is rebuilt from the retained
|
|
693
724
|
// frames only (older frames are unreachable for rewind anyway); blobs only
|
|
694
725
|
// those frames referenced are pruned best-effort, mirroring endTurn.
|
|
695
|
-
const retained = snapshots
|
|
726
|
+
const retained = capSnapshots(snapshots, maxSnapshots);
|
|
696
727
|
if (retained.length < snapshots.length) {
|
|
697
728
|
void pruneDroppedBlobs(sid, snapshots.slice(0, snapshots.length - retained.length), retained);
|
|
698
729
|
}
|