@selesai/code 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/config.d.ts +1 -1
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +24 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/core/git-command.d.ts +9 -0
  6. package/dist/core/git-command.d.ts.map +1 -0
  7. package/dist/core/git-command.js +49 -0
  8. package/dist/core/git-command.js.map +1 -0
  9. package/dist/core/slash-commands.d.ts.map +1 -1
  10. package/dist/core/slash-commands.js +1 -0
  11. package/dist/core/slash-commands.js.map +1 -1
  12. package/dist/extensions/handoff-new.test.ts +210 -0
  13. package/dist/extensions/handoff-new.ts +172 -0
  14. package/dist/extensions/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  15. package/dist/extensions/package.json +1 -0
  16. package/dist/extensions/test-resolve-hook-impl.mjs +7 -0
  17. package/dist/extensions/test-resolve-hook.mjs +7 -0
  18. package/dist/extensions/workflow/adapter.ts +162 -6
  19. package/dist/extensions/workflow/modes/prototype.ts +42 -17
  20. package/dist/extensions/workflow/modes/quick.ts +40 -17
  21. package/dist/extensions/workflow/state-machine.ts +117 -15
  22. package/dist/extensions/workflow/validators.ts +42 -0
  23. package/dist/modes/interactive/interactive-mode.d.ts +1 -0
  24. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  25. package/dist/modes/interactive/interactive-mode.js +31 -0
  26. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  27. package/dist/package-manager-cli.d.ts.map +1 -1
  28. package/dist/package-manager-cli.js +23 -8
  29. package/dist/package-manager-cli.js.map +1 -1
  30. package/dist/skills/improve-codebase/SKILL.md +2 -2
  31. package/dist/utils/version-check.d.ts +3 -0
  32. package/dist/utils/version-check.d.ts.map +1 -1
  33. package/dist/utils/version-check.js +19 -0
  34. package/dist/utils/version-check.js.map +1 -1
  35. package/docs/workflows.md +21 -24
  36. package/package.json +5 -5
@@ -11,6 +11,7 @@ export const BUILTIN_SLASH_COMMANDS = [
11
11
  { name: "session", description: "Show session info and stats" },
12
12
  { name: "changelog", description: "Show changelog entries" },
13
13
  { name: "hotkeys", description: "Show all keyboard shortcuts" },
14
+ { name: "git", description: "Git helper: worktrees, checkpoints, commits, and pushes" },
14
15
  { name: "fork", description: "Create a new fork from a previous user message" },
15
16
  { name: "clone", description: "Duplicate the current session at the current position" },
16
17
  { name: "tree", description: "Navigate session tree (switch branches)" },
@@ -1 +1 @@
1
- {"version":3,"file":"slash-commands.js","sourceRoot":"","sources":["../../src/core/slash-commands.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAiBxC,MAAM,CAAC,MAAM,sBAAsB,GAAuC;IACzE,EAAE,IAAI,EAAE,UAAU,EAAE,WAAW,EAAE,oBAAoB,EAAE;IACvD,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,kCAAkC,EAAE;IAClE,EAAE,IAAI,EAAE,eAAe,EAAE,WAAW,EAAE,0CAA0C,EAAE;IAClF,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,8DAA8D,EAAE;IAC/F,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,+CAA+C,EAAE;IAChF,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,uCAAuC,EAAE;IACvE,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,sCAAsC,EAAE;IACrE,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,0BAA0B,EAAE;IACzD,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,6BAA6B,EAAE;IAC/D,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,wBAAwB,EAAE;IAC5D,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,6BAA6B,EAAE;IAC/D,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,gDAAgD,EAAE;IAC/E,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,uDAAuD,EAAE;IACvF,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,yCAAyC,EAAE;IACxE,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,iDAAiD,EAAE;IACjF,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,mCAAmC,EAAE;IACnE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,gCAAgC,EAAE;IACjE,EAAE,IAAI,EAAE,KAAK,EAAE,WAAW,EAAE,qBAAqB,EAAE;IACnD,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,sCAAsC,EAAE;IACxE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,4BAA4B,EAAE;IAC7D,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,6DAA6D,EAAE;IAC9F,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,QAAQ,QAAQ,EAAE,EAAE;CACjD,CAAC","sourcesContent":["import { APP_NAME } from \"../config.ts\";\nimport type { SourceInfo } from \"./source-info.ts\";\n\nexport type SlashCommandSource = \"extension\" | \"prompt\" | \"skill\";\n\nexport interface SlashCommandInfo {\n\tname: string;\n\tdescription?: string;\n\tsource: SlashCommandSource;\n\tsourceInfo: SourceInfo;\n}\n\nexport interface BuiltinSlashCommand {\n\tname: string;\n\tdescription: string;\n}\n\nexport const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [\n\t{ name: \"settings\", description: \"Open settings menu\" },\n\t{ name: \"model\", description: \"Select model (opens selector UI)\" },\n\t{ name: \"scoped-models\", description: \"Enable/disable models for Ctrl+P cycling\" },\n\t{ name: \"export\", description: \"Export session (HTML default, or specify path: .html/.jsonl)\" },\n\t{ name: \"import\", description: \"Import and resume a session from a JSONL file\" },\n\t{ name: \"share\", description: \"Share session as a secret GitHub gist\" },\n\t{ name: \"copy\", description: \"Copy last agent message to clipboard\" },\n\t{ name: \"name\", description: \"Set session display name\" },\n\t{ name: \"session\", description: \"Show session info and stats\" },\n\t{ name: \"changelog\", description: \"Show changelog entries\" },\n\t{ name: \"hotkeys\", description: \"Show all keyboard shortcuts\" },\n\t{ name: \"fork\", description: \"Create a new fork from a previous user message\" },\n\t{ name: \"clone\", description: \"Duplicate the current session at the current position\" },\n\t{ name: \"tree\", description: \"Navigate session tree (switch branches)\" },\n\t{ name: \"trust\", description: \"Save project trust decision for future sessions\" },\n\t{ name: \"login\", description: \"Configure provider authentication\" },\n\t{ name: \"logout\", description: \"Remove provider authentication\" },\n\t{ name: \"new\", description: \"Start a new session\" },\n\t{ name: \"compact\", description: \"Manually compact the session context\" },\n\t{ name: \"resume\", description: \"Resume a different session\" },\n\t{ name: \"reload\", description: \"Reload keybindings, extensions, skills, prompts, and themes\" },\n\t{ name: \"quit\", description: `Quit ${APP_NAME}` },\n];\n"]}
1
+ {"version":3,"file":"slash-commands.js","sourceRoot":"","sources":["../../src/core/slash-commands.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAiBxC,MAAM,CAAC,MAAM,sBAAsB,GAAuC;IACzE,EAAE,IAAI,EAAE,UAAU,EAAE,WAAW,EAAE,oBAAoB,EAAE;IACvD,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,kCAAkC,EAAE;IAClE,EAAE,IAAI,EAAE,eAAe,EAAE,WAAW,EAAE,0CAA0C,EAAE;IAClF,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,8DAA8D,EAAE;IAC/F,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,+CAA+C,EAAE;IAChF,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,uCAAuC,EAAE;IACvE,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,sCAAsC,EAAE;IACrE,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,0BAA0B,EAAE;IACzD,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,6BAA6B,EAAE;IAC/D,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,EAAE,wBAAwB,EAAE;IAC5D,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,6BAA6B,EAAE;IAC/D,EAAE,IAAI,EAAE,KAAK,EAAE,WAAW,EAAE,yDAAyD,EAAE;IACvF,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,gDAAgD,EAAE;IAC/E,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,uDAAuD,EAAE;IACvF,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,yCAAyC,EAAE;IACxE,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,iDAAiD,EAAE;IACjF,EAAE,IAAI,EAAE,OAAO,EAAE,WAAW,EAAE,mCAAmC,EAAE;IACnE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,gCAAgC,EAAE;IACjE,EAAE,IAAI,EAAE,KAAK,EAAE,WAAW,EAAE,qBAAqB,EAAE;IACnD,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,sCAAsC,EAAE;IACxE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,4BAA4B,EAAE;IAC7D,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,6DAA6D,EAAE;IAC9F,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,QAAQ,QAAQ,EAAE,EAAE;CACjD,CAAC","sourcesContent":["import { APP_NAME } from \"../config.ts\";\nimport type { SourceInfo } from \"./source-info.ts\";\n\nexport type SlashCommandSource = \"extension\" | \"prompt\" | \"skill\";\n\nexport interface SlashCommandInfo {\n\tname: string;\n\tdescription?: string;\n\tsource: SlashCommandSource;\n\tsourceInfo: SourceInfo;\n}\n\nexport interface BuiltinSlashCommand {\n\tname: string;\n\tdescription: string;\n}\n\nexport const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [\n\t{ name: \"settings\", description: \"Open settings menu\" },\n\t{ name: \"model\", description: \"Select model (opens selector UI)\" },\n\t{ name: \"scoped-models\", description: \"Enable/disable models for Ctrl+P cycling\" },\n\t{ name: \"export\", description: \"Export session (HTML default, or specify path: .html/.jsonl)\" },\n\t{ name: \"import\", description: \"Import and resume a session from a JSONL file\" },\n\t{ name: \"share\", description: \"Share session as a secret GitHub gist\" },\n\t{ name: \"copy\", description: \"Copy last agent message to clipboard\" },\n\t{ name: \"name\", description: \"Set session display name\" },\n\t{ name: \"session\", description: \"Show session info and stats\" },\n\t{ name: \"changelog\", description: \"Show changelog entries\" },\n\t{ name: \"hotkeys\", description: \"Show all keyboard shortcuts\" },\n\t{ name: \"git\", description: \"Git helper: worktrees, checkpoints, commits, and pushes\" },\n\t{ name: \"fork\", description: \"Create a new fork from a previous user message\" },\n\t{ name: \"clone\", description: \"Duplicate the current session at the current position\" },\n\t{ name: \"tree\", description: \"Navigate session tree (switch branches)\" },\n\t{ name: \"trust\", description: \"Save project trust decision for future sessions\" },\n\t{ name: \"login\", description: \"Configure provider authentication\" },\n\t{ name: \"logout\", description: \"Remove provider authentication\" },\n\t{ name: \"new\", description: \"Start a new session\" },\n\t{ name: \"compact\", description: \"Manually compact the session context\" },\n\t{ name: \"resume\", description: \"Resume a different session\" },\n\t{ name: \"reload\", description: \"Reload keybindings, extensions, skills, prompts, and themes\" },\n\t{ name: \"quit\", description: `Quit ${APP_NAME}` },\n];\n"]}
@@ -0,0 +1,210 @@
1
+ // Tests for /handoff-new. These import runtime values from "@selesai/code",
2
+ // which is this package itself and only resolves against a built dist.
3
+ // Run with the resolve hook that maps @selesai/code -> src/index.ts:
4
+ // node --import ./src/extensions/test-resolve-hook.mjs --test src/extensions/handoff-new.test.ts
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+
8
+ import handoffNewExtension, {
9
+ DEFAULT_GOAL,
10
+ buildAiContext,
11
+ entryToMessage,
12
+ getHandoffMessages,
13
+ } from "./handoff-new.ts";
14
+ import type { SessionEntry } from "@selesai/code";
15
+
16
+ // ---- registration ----
17
+
18
+ type Cmd = { description: string; handler: (a: string, c: any) => Promise<void> };
19
+
20
+ function createPiHarness() {
21
+ const commands = new Map<string, Cmd>();
22
+ const pi = {
23
+ registerCommand(name: string, options: Cmd) {
24
+ commands.set(name, options);
25
+ },
26
+ };
27
+ handoffNewExtension(pi as any);
28
+ return { commands };
29
+ }
30
+
31
+ test("registers both handoff-new and handover-new commands", () => {
32
+ const { commands } = createPiHarness();
33
+ assert.ok(commands.has("handoff-new"));
34
+ assert.ok(commands.has("handover-new"));
35
+ assert.equal(commands.get("handoff-new")?.handler, commands.get("handover-new")?.handler);
36
+ });
37
+
38
+ // ---- pure helpers ----
39
+
40
+ function userEntry(text: string, id = "u1"): SessionEntry {
41
+ return {
42
+ id,
43
+ type: "message",
44
+ message: { role: "user", content: [{ type: "text", text }], timestamp: 100 },
45
+ parentId: "root",
46
+ timestamp: 100,
47
+ } as any as SessionEntry;
48
+ }
49
+
50
+ function compactionEntry(id: string, firstKeptEntryId: string): SessionEntry {
51
+ return {
52
+ id,
53
+ type: "compaction",
54
+ summary: "compacted summary",
55
+ tokensBefore: 1000,
56
+ firstKeptEntryId,
57
+ timestamp: 50,
58
+ } as any as SessionEntry;
59
+ }
60
+
61
+ test("entryToMessage: message entry -> message", () => {
62
+ const e = userEntry("hi");
63
+ assert.deepEqual(entryToMessage(e), (e as any).message);
64
+ });
65
+
66
+ test("entryToMessage: compaction entry -> compactionSummary", () => {
67
+ const e = compactionEntry("c1", "k1");
68
+ const m: any = entryToMessage(e);
69
+ assert.equal(m.role, "compactionSummary");
70
+ assert.equal(m.summary, "compacted summary");
71
+ });
72
+
73
+ test("entryToMessage: other entry types -> undefined", () => {
74
+ assert.equal(entryToMessage({ type: "label", id: "l1" } as any as SessionEntry), undefined);
75
+ });
76
+
77
+ test("getHandoffMessages: no compaction returns all messages", () => {
78
+ const branch = [userEntry("a", "1"), userEntry("b", "2")];
79
+ assert.equal(getHandoffMessages(branch).length, 2);
80
+ });
81
+
82
+ test("getHandoffMessages: compaction prepends summary + kept slice + after", () => {
83
+ const branch = [
84
+ userEntry("old-kept", "k1"),
85
+ compactionEntry("c1", "k1"),
86
+ userEntry("after", "a1"),
87
+ ];
88
+ const msgs = getHandoffMessages(branch);
89
+ assert.equal(msgs.length, 3);
90
+ assert.equal((msgs[0] as any).role, "compactionSummary");
91
+ assert.equal((msgs[1] as any).content[0].text, "old-kept");
92
+ assert.equal((msgs[2] as any).content[0].text, "after");
93
+ });
94
+
95
+ test("buildAiContext: embeds conversation + goal into one user turn", () => {
96
+ const ctx = buildAiContext("conv-text", "my-goal");
97
+ assert.ok(ctx.systemPrompt.length > 0);
98
+ assert.equal(ctx.messages.length, 1);
99
+ const text = (ctx.messages[0].content[0] as any).text;
100
+ assert.ok(text.includes("conv-text"));
101
+ assert.ok(text.includes("my-goal"));
102
+ });
103
+
104
+ // ---- handler ----
105
+
106
+ function createCtx(opts: {
107
+ branch?: SessionEntry[];
108
+ model?: any;
109
+ editorResult?: string;
110
+ customResult?: string | null;
111
+ mode?: string;
112
+ } = {}) {
113
+ const calls: { newSession: any; editorText: string | null; notify: { msg: string; kind: string }[] } = {
114
+ newSession: null,
115
+ editorText: null,
116
+ notify: [],
117
+ };
118
+ const ctx: any = {
119
+ mode: opts.mode ?? "tui",
120
+ model: opts.model === undefined ? { provider: "test", id: "m" } : opts.model,
121
+ sessionManager: {
122
+ getBranch: () => opts.branch === undefined ? [userEntry("we decided to build X")] : opts.branch,
123
+ getSessionFile: () => "/tmp/old.json",
124
+ },
125
+ modelRegistry: {
126
+ async getApiKeyAndHeaders() {
127
+ return { ok: true, apiKey: "key", headers: {} };
128
+ },
129
+ },
130
+ ui: {
131
+ notify: (msg: string, kind: string) => calls.notify.push({ msg, kind }),
132
+ custom: async <T>(_factory: any): Promise<T> => (opts.customResult === undefined ? "HANDOFF PROMPT" : opts.customResult) as unknown as T,
133
+ editor: async (_title: string, prefill?: string) => ("editorResult" in opts ? opts.editorResult : prefill),
134
+ setEditorText: (text: string) => {
135
+ calls.editorText = text;
136
+ },
137
+ },
138
+ async newSession(options: any) {
139
+ calls.newSession = options;
140
+ if (options?.withSession) {
141
+ await options.withSession(ctx);
142
+ }
143
+ return { cancelled: false };
144
+ },
145
+ };
146
+ return { ctx, calls };
147
+ }
148
+
149
+ test("happy path: newSession called with parentSession, editor text set", async () => {
150
+ const { commands } = createPiHarness();
151
+ const { ctx, calls } = createCtx();
152
+ await commands.get("handoff-new")!.handler("continue the fix", ctx);
153
+ assert.equal(calls.newSession.parentSession, "/tmp/old.json");
154
+ assert.equal(calls.editorText, "HANDOFF PROMPT");
155
+ assert.equal(calls.newSession.cancelled, undefined);
156
+ });
157
+
158
+ test("no goal arg uses DEFAULT_GOAL", async () => {
159
+ const { commands } = createPiHarness();
160
+ const { ctx, calls } = createCtx();
161
+ await commands.get("handoff-new")!.handler(" ", ctx);
162
+ // happy path still completes; default goal only affects the AI prompt text.
163
+ assert.equal(calls.editorText, "HANDOFF PROMPT");
164
+ assert.ok(calls.notify.some((n) => /Handoff ready/.test(n.msg)));
165
+ });
166
+
167
+ test("non-tui mode fails cleanly, no new session", async () => {
168
+ const { commands } = createPiHarness();
169
+ const { ctx, calls } = createCtx({ mode: "rpc" });
170
+ await commands.get("handoff-new")!.handler("goal", ctx);
171
+ assert.equal(calls.newSession, null);
172
+ assert.ok(calls.notify.some((n) => /interactive mode/.test(n.msg)));
173
+ });
174
+
175
+ test("no model fails cleanly", async () => {
176
+ const { commands } = createPiHarness();
177
+ const { ctx, calls } = createCtx({ model: null });
178
+ await commands.get("handoff-new")!.handler("goal", ctx);
179
+ assert.equal(calls.newSession, null);
180
+ assert.ok(calls.notify.some((n) => /No model/.test(n.msg)));
181
+ });
182
+
183
+ test("empty conversation fails cleanly", async () => {
184
+ const { commands } = createPiHarness();
185
+ const { ctx, calls } = createCtx({ branch: [] });
186
+ await commands.get("handoff-new")!.handler("goal", ctx);
187
+ assert.equal(calls.newSession, null);
188
+ assert.ok(calls.notify.some((n) => /No conversation/.test(n.msg)));
189
+ });
190
+
191
+ test("custom null (cancelled) does not open new session", async () => {
192
+ const { commands } = createPiHarness();
193
+ const { ctx, calls } = createCtx({ customResult: null });
194
+ await commands.get("handoff-new")!.handler("goal", ctx);
195
+ assert.equal(calls.newSession, null);
196
+ assert.ok(calls.notify.some((n) => /Cancelled/.test(n.msg)));
197
+ });
198
+
199
+ test("editor cancel does not open new session", async () => {
200
+ const { commands } = createPiHarness();
201
+ const { ctx, calls } = createCtx({ editorResult: undefined });
202
+ await commands.get("handoff-new")!.handler("goal", ctx);
203
+ assert.equal(calls.newSession, null);
204
+ assert.ok(calls.notify.some((n) => /Cancelled/.test(n.msg)));
205
+ });
206
+
207
+ // sanity: DEFAULT_GOAL exported and non-empty (used by handler)
208
+ test("DEFAULT_GOAL is non-empty", () => {
209
+ assert.ok(DEFAULT_GOAL.length > 0);
210
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * /handoff-new — generate a handoff prompt and open a clean new session with
3
+ * that content as the editable first prompt (editor text, not hidden system
4
+ * prompt). Sibling of examples/extensions/handoff.ts, adapted to this repo's
5
+ * import conventions and reduced to the one thing that ships.
6
+ *
7
+ * Usage:
8
+ * /handoff-new continue implementing the workflow fix
9
+ * /handover-new ship plan 2
10
+ *
11
+ * If no goal argument is given, a default goal is used. The generated text is
12
+ * shown in the editor for review before the user submits it.
13
+ */
14
+
15
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
16
+ import { complete, type Context } from "@earendil-works/pi-ai/compat";
17
+ import type { ExtensionAPI, ExtensionCommandContext, SessionEntry } from "@selesai/code";
18
+ import { BorderedLoader, convertToLlm, serializeConversation } from "@selesai/code";
19
+
20
+ const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that:
21
+
22
+ 1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
23
+ 2. Lists any relevant files that were discussed or modified
24
+ 3. Clearly states the next task based on the user's goal
25
+ 4. Is self-contained - the new thread should be able to proceed without the old conversation
26
+
27
+ Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself.`;
28
+
29
+ export const DEFAULT_GOAL = "Continue the previous session from this handoff.";
30
+
31
+ export function entryToMessage(entry: SessionEntry): AgentMessage | undefined {
32
+ if (entry.type === "message") {
33
+ return entry.message;
34
+ }
35
+ if (entry.type === "compaction") {
36
+ return {
37
+ role: "compactionSummary",
38
+ summary: entry.summary,
39
+ tokensBefore: entry.tokensBefore,
40
+ timestamp: new Date(entry.timestamp).getTime(),
41
+ };
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ export function getHandoffMessages(branch: SessionEntry[]): AgentMessage[] {
47
+ let compactionIndex = -1;
48
+ for (let i = branch.length - 1; i >= 0; i--) {
49
+ if (branch[i].type === "compaction") {
50
+ compactionIndex = i;
51
+ break;
52
+ }
53
+ }
54
+ if (compactionIndex < 0) {
55
+ return branch.map(entryToMessage).filter((m): m is AgentMessage => m !== undefined);
56
+ }
57
+
58
+ const compaction = branch[compactionIndex];
59
+ const firstKeptIndex =
60
+ compaction.type === "compaction" ? branch.findIndex((entry) => entry.id === compaction.firstKeptEntryId) : -1;
61
+ const compactedBranch = [
62
+ compaction,
63
+ ...(firstKeptIndex >= 0 ? branch.slice(firstKeptIndex, compactionIndex) : []),
64
+ ...branch.slice(compactionIndex + 1),
65
+ ];
66
+ return compactedBranch.map(entryToMessage).filter((m): m is AgentMessage => m !== undefined);
67
+ }
68
+
69
+ export function buildAiContext(conversationText: string, goal: string): Context {
70
+ return {
71
+ systemPrompt: SYSTEM_PROMPT,
72
+ messages: [
73
+ {
74
+ role: "user",
75
+ content: [
76
+ {
77
+ type: "text",
78
+ text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
79
+ },
80
+ ],
81
+ timestamp: Date.now(),
82
+ },
83
+ ],
84
+ };
85
+ }
86
+
87
+ export default function (pi: ExtensionAPI) {
88
+ // ponytail: single handler behind two command names; no separate config.
89
+ for (const name of ["handoff-new", "handover-new"]) {
90
+ pi.registerCommand(name, {
91
+ description: "Generate a handoff prompt and open a clean new session with it as the first draft",
92
+ handler: handoffNew,
93
+ });
94
+ }
95
+ }
96
+
97
+ async function handoffNew(args: string, ctx: ExtensionCommandContext) {
98
+ if (ctx.mode !== "tui") {
99
+ ctx.ui.notify("handoff-new requires interactive mode", "error");
100
+ return;
101
+ }
102
+ if (!ctx.model) {
103
+ ctx.ui.notify("No model selected", "error");
104
+ return;
105
+ }
106
+
107
+ const goal = args.trim() || DEFAULT_GOAL;
108
+
109
+ const messages = getHandoffMessages(ctx.sessionManager.getBranch());
110
+ if (messages.length === 0) {
111
+ ctx.ui.notify("No conversation to hand off", "error");
112
+ return;
113
+ }
114
+
115
+ const llmMessages = convertToLlm(messages);
116
+ const conversationText = serializeConversation(llmMessages);
117
+ const currentSessionFile = ctx.sessionManager.getSessionFile();
118
+ const aiContext = buildAiContext(conversationText, goal);
119
+
120
+ const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
121
+ const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`);
122
+ loader.onAbort = () => done(null);
123
+
124
+ (async () => {
125
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!);
126
+ if (!auth.ok || !auth.apiKey) {
127
+ throw new Error(auth.ok ? `No API key for ${ctx.model!.provider}` : auth.error);
128
+ }
129
+ const response = await complete(ctx.model!, aiContext, {
130
+ apiKey: auth.apiKey,
131
+ headers: auth.headers,
132
+ signal: loader.signal,
133
+ });
134
+ if (response.stopReason === "aborted") return null;
135
+ return response.content
136
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
137
+ .map((c) => c.text)
138
+ .join("\n");
139
+ })()
140
+ .then(done)
141
+ .catch((err) => {
142
+ console.error("handoff-new generation failed:", err);
143
+ done(null);
144
+ });
145
+
146
+ return loader;
147
+ });
148
+
149
+ if (result === null) {
150
+ ctx.ui.notify("Cancelled", "info");
151
+ return;
152
+ }
153
+
154
+ const editedPrompt = await ctx.ui.editor("Edit handoff prompt", result);
155
+ if (editedPrompt === undefined) {
156
+ ctx.ui.notify("Cancelled", "info");
157
+ return;
158
+ }
159
+
160
+ // Editor text, NOT a hidden system prompt: the user reviews and submits.
161
+ const newSessionResult = await ctx.newSession({
162
+ parentSession: currentSessionFile,
163
+ withSession: async (replacementCtx) => {
164
+ replacementCtx.ui.setEditorText(editedPrompt);
165
+ replacementCtx.ui.notify("Handoff ready. Submit when ready.", "info");
166
+ },
167
+ });
168
+
169
+ if (newSessionResult.cancelled) {
170
+ ctx.ui.notify("New session cancelled", "info");
171
+ }
172
+ }
@@ -0,0 +1 @@
1
+ {"version":"4.1.9","results":[[":handoff-new.test.ts",{"duration":0,"failed":true}]]}
@@ -9,6 +9,7 @@
9
9
  "./gitignore-guard.ts",
10
10
  "./tool-error-autofix.ts",
11
11
  "./question",
12
+ "./handoff-new.ts",
12
13
  "./rtk.ts",
13
14
  "./tokenin-onboarding.ts",
14
15
  "./undo.ts",
@@ -0,0 +1,7 @@
1
+ export async function resolve(specifier, context, next) {
2
+ if (specifier === "@selesai/code" || specifier.startsWith("@selesai/code/")) {
3
+ const url = new URL("../../src/index.ts", import.meta.url);
4
+ return next(url.href, context);
5
+ }
6
+ return next(specifier, context);
7
+ }
@@ -0,0 +1,7 @@
1
+ // Register ESM resolve hook to rewrite "@selesai/code" -> this repo's src.
2
+ // Node 24 loads .ts natively (no jiti), so we need a real resolve hook.
3
+ // Usage: node --import ./src/extensions/test-resolve-hook.mjs --test <file>
4
+ import { register } from "node:module";
5
+ import { pathToFileURL } from "node:url";
6
+
7
+ register(pathToFileURL("./src/extensions/test-resolve-hook-impl.mjs").href);