claude-code-session-manager 0.109.0 → 0.111.0

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 (60) hide show
  1. package/dist/assets/{AgentLibrary-Br4Ispr4.js → AgentLibrary-rBO1daqM.js} +1 -1
  2. package/dist/assets/{Button-C8Mc6MEP.js → Button-DsEYRZg0.js} +1 -1
  3. package/dist/assets/{DataModel-Bz0MypYO.js → DataModel-D8sbvvBC.js} +1 -1
  4. package/dist/assets/{History-DT26Iy38.js → History-BEHLtxia.js} +1 -1
  5. package/dist/assets/{Hooks-e4xUGEf-.js → Hooks-CKpvlXrg.js} +3 -3
  6. package/dist/assets/{Library-B3rzEhvN.js → Library-CeF4qh9d.js} +1 -1
  7. package/dist/assets/{ListDetail-BAvci_aP.js → ListDetail-DcfmxPFk.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DBWTGgRz.js → MarkdownEditor-CvK1pyto.js} +1 -1
  9. package/dist/assets/{McpServers-C-grBN21.js → McpServers-D-h2ihzJ.js} +2 -2
  10. package/dist/assets/{Memory-BmM0Fz-C.js → Memory-BnF89x8v.js} +1 -1
  11. package/dist/assets/{Panel-Dc6F8wb4.js → Panel-CQY5zano.js} +1 -1
  12. package/dist/assets/{Permissions-m_NnldFh.js → Permissions-DLukmmsz.js} +3 -3
  13. package/dist/assets/{Plugins-BB0TLUC9.js → Plugins-0Pf0OZ4l.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-k9PH0S2n.js → ProvenanceBadge-vC7Tj-JP.js} +1 -1
  15. package/dist/assets/RemoteMachines-CWnKbFHc.js +3 -0
  16. package/dist/assets/{SaveBar-DSMnbUVI.js → SaveBar-DjijiUb0.js} +1 -1
  17. package/dist/assets/{Scheduler-CbIcJEfM.js → Scheduler-Cm3EXZvE.js} +7 -7
  18. package/dist/assets/{ScopeSwitcher-tHtloWbN.js → ScopeSwitcher-B-P-kSfq.js} +1 -1
  19. package/dist/assets/Settings-DYDlp2m_.js +4 -0
  20. package/dist/assets/{SkillReferenceGraph-Dndl1tzr.js → SkillReferenceGraph-Epv2GJmj.js} +1 -1
  21. package/dist/assets/{Skills-CGnxroik.js → Skills-DHO0N0C2.js} +1 -1
  22. package/dist/assets/{SystemPrompt-B3A3rHNp.js → SystemPrompt-DxeNofL9.js} +1 -1
  23. package/dist/assets/{TagLibrary-k4CTiAYo.js → TagLibrary-QOAs7ISO.js} +1 -1
  24. package/dist/assets/{TiptapBody-C2LfNQXw.js → TiptapBody-DAMO3DhO.js} +1 -1
  25. package/dist/assets/{Toggle-CTXTU1q5.js → Toggle-Ba_f7q_m.js} +1 -1
  26. package/dist/assets/{ViewTabs-CfEfOSE1.js → ViewTabs-CGX6MAhc.js} +1 -1
  27. package/dist/assets/{historyTotals-Db0xy2Fg.js → historyTotals-vgfk8EOL.js} +1 -1
  28. package/dist/assets/index-CPieAFsl.js +3078 -0
  29. package/dist/assets/{index-C8Smiz7z.css → index-Dutcsly-.css} +1 -1
  30. package/dist/assets/{settingsSchema-xxO9p4yF.js → settingsSchema-D0FxNrTy.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-DLS1_moj.js → useAgentsDirWatch-DCu6BNYN.js} +1 -1
  32. package/dist/assets/useRemotePeers-D4cdJDyh.js +1 -0
  33. package/dist/index.html +2 -2
  34. package/package.json +2 -1
  35. package/plugins/session-manager-dev/skills/develop/SKILL.md +3 -1
  36. package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +48 -15
  37. package/scripts/scheduler-mcp-server.cjs +29 -2
  38. package/src/main/build-info.json +4 -4
  39. package/src/main/chatRunner.cjs +7 -0
  40. package/src/main/index.cjs +6 -0
  41. package/src/main/ipcSchemas.cjs +28 -0
  42. package/src/main/lib/autoUpdater.cjs +190 -0
  43. package/src/main/lib/builtinMacros.cjs +2 -2
  44. package/src/main/lib/crossProjectFeedback.cjs +143 -6
  45. package/src/main/lib/mcpToolCatalog.cjs +12 -3
  46. package/src/main/lib/reaperHelpers.cjs +13 -1
  47. package/src/main/lib/relayClientAuth.cjs +133 -0
  48. package/src/main/lib/remoteClientSessions.cjs +84 -0
  49. package/src/main/lib/shippedPersonaSeeds.cjs +11 -0
  50. package/src/main/lib/updaterSettings.cjs +64 -0
  51. package/src/main/remoteClient.cjs +42 -13
  52. package/src/main/remoteClientIpc.cjs +33 -1
  53. package/src/main/scheduler.cjs +16 -0
  54. package/src/main/updaterIpc.cjs +110 -0
  55. package/src/main/webRemote.cjs +276 -132
  56. package/src/preload/api.d.ts +55 -2
  57. package/src/preload/index.cjs +24 -1
  58. package/src/seed/agents/demo-video-builder.md +62 -19
  59. package/dist/assets/Settings-CW3w7knp.js +0 -6
  60. package/dist/assets/index-fJxuoRvi.js +0 -3077
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.109.0",
3
- "gitSha": "14869c8ae8d91422ea84f5b1e1fb483349544a80",
4
- "gitShortSha": "14869c8a",
2
+ "version": "0.111.0",
3
+ "gitSha": "ca0cbc30d2ee72698993f0f07f878a932843c758",
4
+ "gitShortSha": "ca0cbc30",
5
5
  "gitBranch": "HEAD",
6
- "builtAt": "2026-10-10T21:41:36.398Z",
6
+ "builtAt": "2026-10-11T04:47:08.011Z",
7
7
  "dirty": false
8
8
  }
@@ -970,6 +970,12 @@ function enqueueExternalPrompt(tabId, prompt) {
970
970
  broadcast('chat:external-send', { tabId, prompt });
971
971
  }
972
972
 
973
+ // Asks the renderer to start a new Session (same launchSession a macro press
974
+ // uses) on behalf of the paired phone. Payload is pre-validated by the caller.
975
+ function requestExternalNewSession(payload) {
976
+ broadcast('chat:external-new-session', payload);
977
+ }
978
+
973
979
  /**
974
980
  * Registers the admin-HTTP entry point for enqueueExternalPrompt, mirroring
975
981
  * prdCreate.cjs's registerAdminRoute pattern (auth is the injected transport's
@@ -1050,5 +1056,6 @@ module.exports = {
1050
1056
  WARN_CEILING_MS,
1051
1057
  __setExecutor,
1052
1058
  enqueueExternalPrompt,
1059
+ requestExternalNewSession,
1053
1060
  registerAdminRoute,
1054
1061
  };
@@ -130,6 +130,7 @@ const filesIpc = require('./files.cjs');
130
130
  const { registerDocEditHandlers, attachWindow: attachDocEditWindow } = require('./docEdit.cjs');
131
131
  const webRemote = require('./webRemote.cjs');
132
132
  const { registerRemoteClientHandlers } = require('./remoteClientIpc.cjs');
133
+ const { registerUpdaterIpc } = require('./updaterIpc.cjs');
133
134
  const { listExchanges } = require('./exchanges.cjs');
134
135
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
135
136
  const { checkInsideHome, assertInsideHome } = require('./lib/insideHome.cjs');
@@ -1100,6 +1101,11 @@ filesIpc.registerFilesHandlers();
1100
1101
  registerDocEditHandlers();
1101
1102
  webRemote.registerRemoteHandlers();
1102
1103
  registerRemoteClientHandlers(() => mainWindow);
1104
+ try {
1105
+ registerUpdaterIpc({ ipcMain, getWindow: () => mainWindow, ptyManager });
1106
+ } catch (e) {
1107
+ logs.writeLine({ scope: 'main', level: 'error', message: 'updater IPC registration failed', meta: { error: e?.message } });
1108
+ }
1103
1109
  chatRunner.registerChatHandlers();
1104
1110
 
1105
1111
  // Direct in-process PRD authoring for a queued PromptTicket classified
@@ -944,6 +944,19 @@ const chatExternalSend = z.object({
944
944
  ),
945
945
  });
946
946
 
947
+ // Phone-initiated new Session. cwd/agentName are re-checked against the open
948
+ // tabs / persona list in webRemote.cjs before anything touches the filesystem.
949
+ const remoteSessionCreate = z.object({
950
+ cwd: z.string().min(1).max(4096),
951
+ agentName: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/),
952
+ tag: EpicTagSchema,
953
+ title: z.string().min(1).max(120),
954
+ prompt: z.string().min(1).refine(
955
+ (s) => Buffer.byteLength(s, 'utf8') <= CHAT_PROMPT_MAX_BYTES,
956
+ `prompt must be ≤ ${CHAT_PROMPT_MAX_BYTES} bytes`,
957
+ ),
958
+ });
959
+
947
960
  // ──────────────────────────────────────────── Web Remote
948
961
  // OTP is 8 uppercase alphanumeric chars (case-insensitive entry, normalised to upper in handler).
949
962
  const WEB_REMOTE_OTP_RE = /^[A-Z0-9]{8}$/i;
@@ -957,6 +970,11 @@ const webRemoteRevokeDevice = z.object({
957
970
  deviceId: z.string().regex(DEVICE_ID_RE),
958
971
  }).strict();
959
972
 
973
+ // Optional payload: omit (undefined) to confirm the single pending client.
974
+ const webRemoteConfirmSas = z.object({
975
+ clientKey: z.string().max(64).optional(),
976
+ }).strict().optional();
977
+
960
978
  const webRemoteAuditTail = z.object({
961
979
  lines: z.number().int().min(1).max(500).optional(),
962
980
  }).strict();
@@ -1137,9 +1155,11 @@ function validated(schema, handler) {
1137
1155
  //
1138
1156
  // Ungated READ_COMMANDS (justify each):
1139
1157
  // cmd:app:version — exposes only the app semver string; no user data.
1158
+ // cmd:app:info — exposes only app semver + OS platform string; no user data.
1140
1159
  // cmd:session:unsubscribe — teardown lifecycle; returns nothing sensitive.
1141
1160
  const READ_COMMANDS = new Set([
1142
1161
  'cmd:app:version',
1162
+ 'cmd:app:info',
1143
1163
  // v2 mobile: unsubscribe is a teardown lifecycle call with no data payload.
1144
1164
  'cmd:session:unsubscribe',
1145
1165
  ]);
@@ -1147,9 +1167,13 @@ const READ_COMMANDS = new Set([
1147
1167
  // Sensitive reads — return user data; require SAS confirmation same as MUTATE.
1148
1168
  const SAS_GATED_READS = new Set([
1149
1169
  'cmd:sessions:load',
1170
+ // Same data as the event:session:list push (projects + their active Epics).
1171
+ 'cmd:sessions:list',
1150
1172
  'cmd:history:aggregate',
1151
1173
  // subscribe initiates a live stream of session state/summary — sensitive.
1152
1174
  'cmd:session:subscribe',
1175
+ // Picker data (projects, personas, tags, macros) for the phone's New Session.
1176
+ 'cmd:session:options',
1153
1177
  // NOTE: cmd:exchanges:list is intentionally NOT allowlisted — webRemote.cjs has
1154
1178
  // no dispatch handler for it, so an allowlist entry would only fail closed with
1155
1179
  // an opaque reject. Re-add here together with the handler when remote exchanges
@@ -1175,6 +1199,8 @@ const MUTATE_COMMANDS = new Set([
1175
1199
  // Pushes a prompt into an open tab's chat queue — a real mutation of that
1176
1200
  // tab's session, same tier as pty:write.
1177
1201
  'cmd:chat:send',
1202
+ // Starts a new Session on the desktop via the renderer's launchSession.
1203
+ 'cmd:session:create',
1178
1204
  ]);
1179
1205
 
1180
1206
  const ALLOWED_COMMANDS = new Set([...READ_COMMANDS, ...SAS_GATED_READS, ...MUTATE_COMMANDS]);
@@ -1193,6 +1219,7 @@ module.exports = {
1193
1219
  webRemotePair,
1194
1220
  webRemoteRevokeDevice,
1195
1221
  webRemoteAuditTail,
1222
+ webRemoteConfirmSas,
1196
1223
  sessionSubscribe,
1197
1224
  ptySpawn,
1198
1225
  ptyTabId,
@@ -1284,6 +1311,7 @@ module.exports = {
1284
1311
  chatCancel,
1285
1312
  chatClassifyTicket,
1286
1313
  chatExternalSend,
1314
+ remoteSessionCreate,
1287
1315
  exchangesList,
1288
1316
  agentsSavePersona,
1289
1317
  macrosSave,
@@ -0,0 +1,190 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * autoUpdater.cjs — pure, dependency-injected state machine around electron-updater's autoUpdater.
5
+ *
6
+ * Requires neither electron nor electron-updater: the caller injects the updater instance. The feed is
7
+ * whatever app-update.yml electron-builder baked into the installer; this module never supplies a feed URL.
8
+ * Checks on start() and every 4h, downloads in the background, and installs ONLY after requestInstall() —
9
+ * deferring (polling isBusy every 60s) while scheduler jobs or terminals are busy.
10
+ */
11
+
12
+ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000;
13
+ const BUSY_POLL_MS = 60 * 1000;
14
+
15
+ /** Unsigned mac builds cannot use Squirrel.Mac, so darwin is always unsupported. */
16
+ function isAutoUpdateSupported({ packaged, platform, env }) {
17
+ const e = env || {};
18
+ if (packaged !== true) return false;
19
+ if (e.SM_AUTO_UPDATE === '0' || e.SM_E2E === '1') return false;
20
+ if (platform === 'win32') return true;
21
+ return platform === 'linux' && typeof e.APPIMAGE === 'string' && e.APPIMAGE.length > 0;
22
+ }
23
+
24
+ function createAutoUpdater({
25
+ updater, supported, currentVersion, isBusy, getEnabled,
26
+ setInterval: setIntervalFn = setInterval, clearInterval: clearIntervalFn = clearInterval,
27
+ now = Date.now, log = () => {},
28
+ }) {
29
+ let enabledOverride = null;
30
+ let started = false;
31
+ let listening = false;
32
+ let checkTimer = null;
33
+ let pollTimer = null;
34
+ const subscribers = new Set();
35
+
36
+ const isEnabled = () => {
37
+ if (enabledOverride !== null) return enabledOverride;
38
+ return typeof getEnabled === 'function' ? getEnabled() !== false : true;
39
+ };
40
+
41
+ let snapshot = {
42
+ state: supported ? 'idle' : 'unsupported',
43
+ current: currentVersion,
44
+ latest: null,
45
+ progress: null,
46
+ error: null,
47
+ busyReason: null,
48
+ lastCheckedAt: null,
49
+ };
50
+
51
+ function setState(patch) {
52
+ const next = { ...snapshot, ...patch };
53
+ if (Object.keys(next).every((k) => next[k] === snapshot[k])) return;
54
+ snapshot = next;
55
+ for (const fn of [...subscribers]) {
56
+ try { fn(snapshot); } catch (e) { log('autoUpdater subscriber threw', e); }
57
+ }
58
+ }
59
+
60
+ const holding = () => snapshot.state === 'ready' || snapshot.state === 'deferred';
61
+
62
+ function fail(e) {
63
+ const message = e && e.message ? e.message : String(e);
64
+ log('autoUpdater error', message);
65
+ setState({ state: 'error', error: message, progress: null });
66
+ }
67
+
68
+ function clearPoll() {
69
+ if (pollTimer !== null) { clearIntervalFn(pollTimer); pollTimer = null; }
70
+ }
71
+
72
+ function clearChecks() {
73
+ if (checkTimer !== null) { clearIntervalFn(checkTimer); checkTimer = null; }
74
+ }
75
+
76
+ function attach() {
77
+ if (listening) return;
78
+ listening = true;
79
+ updater.autoDownload = true;
80
+ updater.autoInstallOnAppQuit = true;
81
+ updater.allowPrerelease = false;
82
+ updater.allowDowngrade = false;
83
+ updater.on('checking-for-update', () => {
84
+ if (!holding()) setState({ state: 'checking', error: null });
85
+ });
86
+ updater.on('update-available', (info) => {
87
+ if (!holding()) setState({ state: 'downloading', latest: info && info.version ? info.version : null, progress: null, error: null });
88
+ });
89
+ updater.on('download-progress', (p) => {
90
+ if (!holding()) setState({ state: 'downloading', progress: p && typeof p.percent === 'number' ? p.percent : null });
91
+ });
92
+ updater.on('update-downloaded', (info) => {
93
+ setState({ state: 'ready', latest: info && info.version ? info.version : snapshot.latest, progress: 100, error: null, busyReason: null });
94
+ });
95
+ updater.on('update-not-available', () => {
96
+ if (!holding()) setState({ state: 'idle', latest: null, progress: null, error: null });
97
+ });
98
+ updater.on('error', (e) => {
99
+ if (!holding()) fail(e);
100
+ });
101
+ }
102
+
103
+ async function checkNow() {
104
+ if (!supported || !isEnabled()) return;
105
+ if (holding() || snapshot.state === 'checking' || snapshot.state === 'downloading') return;
106
+ attach();
107
+ setState({ state: 'checking', error: null, lastCheckedAt: now() });
108
+ try {
109
+ await updater.checkForUpdates();
110
+ } catch (e) {
111
+ fail(e);
112
+ }
113
+ }
114
+
115
+ function startChecks() {
116
+ clearChecks();
117
+ checkTimer = setIntervalFn(() => { checkNow(); }, CHECK_INTERVAL_MS);
118
+ if (checkTimer && typeof checkTimer.unref === 'function') checkTimer.unref();
119
+ checkNow();
120
+ }
121
+
122
+ function applyEnabled() {
123
+ if (!supported) return;
124
+ if (!isEnabled()) {
125
+ clearChecks();
126
+ clearPoll();
127
+ setState({ state: 'disabled', busyReason: null, progress: null, error: null });
128
+ return;
129
+ }
130
+ if (snapshot.state === 'disabled') setState({ state: 'idle' });
131
+ if (started) startChecks();
132
+ }
133
+
134
+ function start() {
135
+ if (!supported || started) return;
136
+ started = true;
137
+ applyEnabled();
138
+ }
139
+
140
+ function stop() {
141
+ started = false;
142
+ clearChecks();
143
+ clearPoll();
144
+ }
145
+
146
+ function setEnabled(value) {
147
+ enabledOverride = !!value;
148
+ applyEnabled();
149
+ }
150
+
151
+ function install() {
152
+ clearPoll();
153
+ try {
154
+ updater.quitAndInstall(false, true);
155
+ } catch (e) {
156
+ fail(e);
157
+ }
158
+ }
159
+
160
+ function requestInstall({ force } = {}) {
161
+ if (!supported || !holding()) return;
162
+ if (snapshot.state === 'deferred' && !force) return;
163
+ if (force) { install(); return; }
164
+ const b = isBusy ? isBusy() : { busy: false };
165
+ if (!b || !b.busy) { install(); return; }
166
+ setState({ state: 'deferred', busyReason: b.reason || null });
167
+ clearPoll();
168
+ pollTimer = setIntervalFn(() => {
169
+ const cur = isBusy ? isBusy() : { busy: false };
170
+ if (cur && cur.busy) { setState({ busyReason: cur.reason || null }); return; }
171
+ install();
172
+ }, BUSY_POLL_MS);
173
+ if (pollTimer && typeof pollTimer.unref === 'function') pollTimer.unref();
174
+ }
175
+
176
+ function cancelDeferred() {
177
+ if (snapshot.state !== 'deferred') return;
178
+ clearPoll();
179
+ setState({ state: 'ready', busyReason: null });
180
+ }
181
+
182
+ function subscribe(fn) {
183
+ subscribers.add(fn);
184
+ return () => { subscribers.delete(fn); };
185
+ }
186
+
187
+ return { start, stop, checkNow, requestInstall, cancelDeferred, setEnabled, status: () => snapshot, subscribe };
188
+ }
189
+
190
+ module.exports = { isAutoUpdateSupported, createAutoUpdater, CHECK_INTERVAL_MS, BUSY_POLL_MS };
@@ -30,9 +30,9 @@ const BUILTIN_MACROS = Object.freeze([
30
30
  tag: 'project-home-builder',
31
31
  surface: 'project-home',
32
32
  projects: ['*'],
33
- builtinVersion: 3,
33
+ builtinVersion: 4,
34
34
  prompt:
35
- 'Generate a 30-second demo video for this project as one self-contained HTML/JavaScript animation that presents its main goals and key features, grounded only in what you read in the repository, with a matching on-device narration track: narrate with Kokoro (kokoro-onnx) using a natural female (af_heart) or male (am_michael) voice per the audio policy in the persona. Save it with a single project_demo_video_write call passing htmlPath.',
35
+ "Generate a character and cute animated JavaScript video: a 30-second demo of this project as one self-contained HTML/JavaScript animation. Ground everything in what you read in the repository — invent an original mascot character whose look, name and personality match the project's theme and purpose, and tell the project's real goals and key features through it. The character, story and visuals must match the project's theme and purpose as read from the repository. Write the narration script first (hook, problem, solution, three features, call to action), then build every scene to match its line, with fun dynamic elements per the persona. Use the style named on a 'Video style: <name>' line in the additional instructions — Professional, Fun, Animated whiteboard, or Kinetic typography — otherwise pick the one that best fits the project. Narrate with Kokoro (kokoro-onnx) using a natural female (af_heart) or male (am_michael) voice per the audio policy in the persona. Save it with a single project_demo_video_write call passing htmlPath.",
36
36
  }),
37
37
  ]);
38
38
 
@@ -83,6 +83,14 @@ const DEFAULT_FEEDBACK_AGENT = 'architect';
83
83
  const MAX_TITLE_CHARS = 200;
84
84
  const MAX_BODY_CHARS = 20000;
85
85
  const MAX_REFERENCES = 20;
86
+ const MAX_MOTIVATION_CHARS = 2000;
87
+ const MAX_LIST_ITEMS = 20;
88
+ const MAX_LIST_ITEM_CHARS = 500;
89
+ const MAX_SERIES_KEY_CHARS = 60;
90
+ const MAX_SERIES_TITLE_CHARS = 120;
91
+ const MAX_SERIES_TOTAL = 50;
92
+ const MAX_ORIGIN_ASK_CHARS = 300;
93
+ const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
86
94
 
87
95
  /** Bound on how many `~/.claude/projects/` folders listFeedbackTargets will
88
96
  * resolve. That directory accumulates one folder per path the CLI was ever
@@ -127,6 +135,29 @@ function isSessionManagerProject(cwd) {
127
135
  return isDirectory(path.join(cwd, OPS_ROOT_DIR));
128
136
  }
129
137
 
138
+ /**
139
+ * resolveOriginAsk(fromCwd, fromEpicId) → string | null
140
+ *
141
+ * The sending Epic's own human goal (single line, ≤ 300 chars), read from the
142
+ * sender project's active-index.json. Never throws: a missing Epic or an
143
+ * unreadable file is just "no origin known".
144
+ */
145
+ function resolveOriginAsk(fromCwd, fromEpicId) {
146
+ if (!fromCwd || !fromEpicId) return null;
147
+ try {
148
+ const sessions = readActiveIndex(fromCwd).sessions;
149
+ if (!Object.prototype.hasOwnProperty.call(sessions, fromEpicId)) return null;
150
+ const goal = singleLine(sessions[fromEpicId] && sessions[fromEpicId].goalText);
151
+ return goal ? goal.slice(0, MAX_ORIGIN_ASK_CHARS) : null;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ function seriesLine(series) {
158
+ return series ? `Part ${series.index} of ${series.total} — ${singleLine(series.title)}` : '';
159
+ }
160
+
130
161
  /**
131
162
  * composeFeedbackIntake({ fromCwd, fromEpicId, title, body, referencePaths })
132
163
  * → { goalText, openingPrompt, sections }
@@ -160,6 +191,12 @@ function composeFeedbackIntake({
160
191
  agentName = DEFAULT_FEEDBACK_AGENT,
161
192
  agentDescription = null,
162
193
  referencePaths = [],
194
+ originAsk = null,
195
+ motivation = '',
196
+ plan = [],
197
+ acceptanceCriteria = [],
198
+ outOfScope = [],
199
+ series = null,
163
200
  } = {}) {
164
201
  const trimmedTitle = singleLine(title);
165
202
  const trimmedBody = String(body ?? '').trim();
@@ -174,21 +211,36 @@ function composeFeedbackIntake({
174
211
  });
175
212
  }
176
213
 
214
+ const fromPath = singleLine(fromCwd);
215
+ const fromName = path.basename(fromPath) || fromPath;
177
216
  const origin = fromEpicId
178
- ? `${singleLine(fromCwd)} (session ${singleLine(fromEpicId)})`
179
- : singleLine(fromCwd);
217
+ ? `${fromName} (${fromPath}, session ${singleLine(fromEpicId)})`
218
+ : `${fromName} (${fromPath})`;
219
+ const askClause = originAsk ? ` It was raised while working on: ${singleLine(originAsk)}.` : '';
180
220
  sections.push({
181
221
  kind: 'input',
182
222
  label: 'Input',
183
223
  text:
184
- `This session is INBOUND FEEDBACK from another project: ${origin}. `
224
+ `This session is INBOUND FEEDBACK from another project: ${origin}.${askClause} `
185
225
  + 'Nobody in this project wrote the report below — an agent working in that project did, and it '
186
226
  + 'may be wrong about this codebase. Verify the claim against the code here before acting on it, '
187
227
  + 'and if it does not hold, say so and close the session rather than building against it.',
188
228
  source: fromCwd,
189
229
  });
190
230
 
191
- const goalBody = trimmedTitle ? `Goal: ${trimmedTitle}\n\n${trimmedBody}` : trimmedBody;
231
+ const trimmedMotivation = String(motivation ?? '').trim();
232
+ const blocks = [];
233
+ if (series) blocks.push(seriesLine(series));
234
+ if (trimmedMotivation) blocks.push(`## Why\n${trimmedMotivation}`);
235
+ if (plan.length) blocks.push(`## Proposed plan\n${plan.map((x, i) => `${i + 1}. ${singleLine(x)}`).join('\n')}`);
236
+ if (acceptanceCriteria.length) {
237
+ blocks.push(`## Acceptance criteria\n${acceptanceCriteria.map((x) => `- [ ] ${singleLine(x)}`).join('\n')}`);
238
+ }
239
+ if (outOfScope.length) blocks.push(`## Out of scope\n${outOfScope.map((x) => `- ${singleLine(x)}`).join('\n')}`);
240
+ blocks.push(`## Details\n${trimmedBody}`);
241
+ const structured = blocks.join('\n\n');
242
+
243
+ const goalBody = trimmedTitle ? `Goal: ${trimmedTitle}\n\n${structured}` : structured;
192
244
  sections.push({ kind: 'goal', label: 'Goal', text: goalBody });
193
245
 
194
246
  for (const p of referencePaths) {
@@ -203,7 +255,7 @@ function composeFeedbackIntake({
203
255
  return out + sep + s.text;
204
256
  }, '');
205
257
 
206
- const bodyText = trimmedTitle ? `${trimmedTitle}\n\n${trimmedBody}` : trimmedBody;
258
+ const bodyText = trimmedTitle ? `${trimmedTitle}\n\n${structured}` : structured;
207
259
  const refLines = referencePaths.map((p) => `Reference: ${singleLine(p)}`);
208
260
  const goalText = refLines.length ? `${bodyText}\n\n${refLines.join('\n')}` : bodyText;
209
261
 
@@ -237,6 +289,66 @@ function validateFeedbackInput(input = {}) {
237
289
  return bad(400, `tag must be one of ${FEEDBACK_TAGS.join(' | ')} (got ${JSON.stringify(tag)})`);
238
290
  }
239
291
 
292
+ const motivation = typeof input.motivation === 'string' ? input.motivation.trim() : '';
293
+ if (!motivation) {
294
+ return bad(400, 'motivation is required — WHY this is needed, in the sending human\'s terms, so the receiving human can judge it');
295
+ }
296
+ if (motivation.length > MAX_MOTIVATION_CHARS) {
297
+ return bad(400, `motivation is ${motivation.length} chars; cap is ${MAX_MOTIVATION_CHARS}`);
298
+ }
299
+
300
+ const listPurpose = {
301
+ plan: 'the ordered steps you propose the receiving project take',
302
+ acceptanceCriteria: 'the checkable conditions that mean this is done',
303
+ outOfScope: 'what this proposal deliberately does not cover',
304
+ };
305
+ const lists = {};
306
+ for (const field of ['plan', 'acceptanceCriteria', 'outOfScope']) {
307
+ const raw = input[field];
308
+ if (raw === undefined || raw === null) { lists[field] = []; continue; }
309
+ if (!Array.isArray(raw)) return bad(400, `${field} must be an array of strings (${listPurpose[field]})`);
310
+ if (raw.length > MAX_LIST_ITEMS) {
311
+ return bad(400, `${field} has ${raw.length} items; cap is ${MAX_LIST_ITEMS} (${listPurpose[field]})`);
312
+ }
313
+ const items = [];
314
+ for (const item of raw) {
315
+ const t = typeof item === 'string' ? item.trim() : '';
316
+ if (!t) return bad(400, `${field} items must be non-blank strings (${listPurpose[field]})`);
317
+ if (t.length > MAX_LIST_ITEM_CHARS) {
318
+ return bad(400, `a ${field} item is ${t.length} chars; cap is ${MAX_LIST_ITEM_CHARS}`);
319
+ }
320
+ items.push(t);
321
+ }
322
+ lists[field] = items;
323
+ }
324
+ if (lists.acceptanceCriteria.length === 0) {
325
+ return bad(400, `acceptanceCriteria is required with at least one item — ${listPurpose.acceptanceCriteria}`);
326
+ }
327
+ if (tag === 'feature' && lists.plan.length === 0) {
328
+ return bad(400, `plan is required with at least one item when tag is 'feature' — ${listPurpose.plan}`);
329
+ }
330
+
331
+ let series = null;
332
+ if (input.series !== undefined && input.series !== null) {
333
+ const sr = input.series;
334
+ const sbad = (why) => bad(400, `series ${why} (series groups one request split across several proposals: { key, title, index, total })`);
335
+ if (typeof sr !== 'object' || Array.isArray(sr)) return sbad('must be an object');
336
+ const key = typeof sr.key === 'string' ? sr.key : '';
337
+ if (!key || key.length > MAX_SERIES_KEY_CHARS || !KEBAB_RE.test(key)) {
338
+ return sbad(`key must be kebab-case, ≤ ${MAX_SERIES_KEY_CHARS} chars`);
339
+ }
340
+ const stitle = typeof sr.title === 'string' ? sr.title.trim() : '';
341
+ if (!stitle || stitle.length > MAX_SERIES_TITLE_CHARS) {
342
+ return sbad(`title must be non-blank, ≤ ${MAX_SERIES_TITLE_CHARS} chars`);
343
+ }
344
+ if (!Number.isInteger(sr.index) || sr.index < 1) return sbad('index must be an integer ≥ 1');
345
+ if (!Number.isInteger(sr.total) || sr.total < 2 || sr.total > MAX_SERIES_TOTAL) {
346
+ return sbad(`total must be an integer 2..${MAX_SERIES_TOTAL}`);
347
+ }
348
+ if (sr.index > sr.total) return sbad('index must be ≤ total');
349
+ series = { key, title: stitle, index: sr.index, total: sr.total };
350
+ }
351
+
240
352
  const referencePaths = Array.isArray(input.referencePaths) ? input.referencePaths : [];
241
353
  if (referencePaths.length > MAX_REFERENCES) {
242
354
  return bad(400, `too many referencePaths (${referencePaths.length}); cap is ${MAX_REFERENCES}`);
@@ -293,6 +405,11 @@ function validateFeedbackInput(input = {}) {
293
405
  title,
294
406
  body,
295
407
  tag,
408
+ motivation,
409
+ plan: lists.plan,
410
+ acceptanceCriteria: lists.acceptanceCriteria,
411
+ outOfScope: lists.outOfScope,
412
+ series,
296
413
  referencePaths: referencePaths.map((p) => String(p)),
297
414
  fromEpicId: input.fromEpicId ? String(input.fromEpicId) : null,
298
415
  agentType: input.agentType ? String(input.agentType) : DEFAULT_FEEDBACK_AGENT,
@@ -337,6 +454,8 @@ async function openFeedbackSession(input, deps = {}) {
337
454
  } catch { /* unresolvable — the proposal still delivers, just without a receipt */ }
338
455
  }
339
456
 
457
+ const originAsk = resolveOriginAsk(v.fromCwd, v.fromEpicId);
458
+
340
459
  const { goalText, openingPrompt, sections } = composeFeedbackIntake({
341
460
  fromCwd: v.fromCwd,
342
461
  fromEpicId: v.fromEpicId,
@@ -345,6 +464,12 @@ async function openFeedbackSession(input, deps = {}) {
345
464
  agentName: v.agentType,
346
465
  agentDescription: v.agentDescription,
347
466
  referencePaths: v.referencePaths,
467
+ originAsk,
468
+ motivation: v.motivation,
469
+ plan: v.plan,
470
+ acceptanceCriteria: v.acceptanceCriteria,
471
+ outOfScope: v.outOfScope,
472
+ series: v.series,
348
473
  });
349
474
 
350
475
  // The Epic's stored identity is the one-line title, not the whole report —
@@ -363,6 +488,14 @@ async function openFeedbackSession(input, deps = {}) {
363
488
  producer: 'cross-project-feedback',
364
489
  fromCwd: v.fromCwd,
365
490
  ...(v.fromEpicId ? { fromEpicId: v.fromEpicId } : {}),
491
+ proposal: {
492
+ motivation: v.motivation,
493
+ originAsk,
494
+ plan: v.plan,
495
+ acceptanceCriteria: v.acceptanceCriteria,
496
+ outOfScope: v.outOfScope,
497
+ series: v.series,
498
+ },
366
499
  },
367
500
  mintAuthority: MINT_AUTHORITY_CROSS_PROJECT_FEEDBACK,
368
501
  });
@@ -377,6 +510,7 @@ async function openFeedbackSession(input, deps = {}) {
377
510
  fromEpicId: v.fromEpicId,
378
511
  tag: v.tag,
379
512
  title: v.title,
513
+ series: v.series,
380
514
  });
381
515
 
382
516
  let receiptOnOriginEpic = false;
@@ -385,7 +519,9 @@ async function openFeedbackSession(input, deps = {}) {
385
519
  receiptOnOriginEpic = await appendReceipt(
386
520
  v.fromCwd,
387
521
  v.fromEpicId,
388
- `Feedback delivered to ${v.toCwd} as a proposed session (${minted.epicId}): "${v.title}". `
522
+ `Feedback delivered to ${v.toCwd} as a proposed session (${minted.epicId}): "${v.title}"`
523
+ + (v.series ? ` [${seriesLine(v.series)}]` : '')
524
+ + '. '
389
525
  + 'It will not run until a human in that project approves it.',
390
526
  );
391
527
  } catch {
@@ -545,6 +681,7 @@ module.exports = {
545
681
  resolveProjectsDir,
546
682
  isSessionManagerProject,
547
683
  composeFeedbackIntake,
684
+ resolveOriginAsk,
548
685
  validateFeedbackInput,
549
686
  openFeedbackSession,
550
687
  listFeedbackTargets,
@@ -238,15 +238,24 @@ const MCP_TOOL_CATALOG = [
238
238
  + 'opening prompt and stamped with where it came from. Session Manager performs the cross-folder '
239
239
  + 'write; you never write another project\'s files yourself.',
240
240
  whenToUse: 'Use when work in this project surfaces something that is genuinely another project\'s to fix. '
241
- + 'Write the report for a reader who has never seen your project: state the symptom, where you '
242
- + 'observed it, what you expected, and (if you know) the file in THEIR repo that looks responsible. '
243
- + 'Never assume they can see your code.',
241
+ + 'Write for a human who must approve it and has never seen your project. Lead with WHY: `motivation` '
242
+ + 'in the originating human\'s terms. Then give the full `plan` (required for tag "feature") and '
243
+ + '`acceptanceCriteria`; put evidence, symptom and references in `body`. Name the file in THEIR repo '
244
+ + 'that looks responsible if you know it. When one request is split into several proposals, send '
245
+ + 'them ALL with one shared `series` {key, title, total} (plus each one\'s `index`) and summarise '
246
+ + 'the overall plan in each motivation.',
244
247
  whenNotToUse: 'WHEN NOT TO USE IT: for work in the project you are ALREADY in, run /develop inside the Epic you '
245
248
  + 'are already in — this tool refuses toCwd === fromCwd outright.',
246
249
  exampleArgs: {
247
250
  toCwd: '/home/bilko/Projects/other-project',
248
251
  fromCwd: '/home/bilko/Projects/session-manager',
249
252
  title: 'Cross-project contract mismatch in the shared admin API',
253
+ tag: 'feature',
254
+ motivation: 'The human wants X to work across both projects; today Y breaks because Z, so the shared admin API needs W.',
255
+ plan: ['Add field F to the admin API response', 'Update the validator to accept F', 'Cover F with a contract test'],
256
+ acceptanceCriteria: ['Admin API returns F', 'Contract test for F passes'],
257
+ outOfScope: ['Migrating existing records'],
258
+ series: { key: 'shared-admin-api-f', title: 'Shared admin API field F', index: 1, total: 2 },
250
259
  body: 'Symptom: X. Observed while doing Y in session-manager. Expected: Z. Suspected file: <path in their repo>.',
251
260
  },
252
261
  notes: 'WHAT THIS DOES NOT DO: it does not start anything, queue a PRD, or spend a token. The session '
@@ -339,6 +339,14 @@ const ORPHAN_REQUEUE_CAP = 5;
339
339
  * diverted into `recovered` exactly like a `findLiveProcess` hit.
340
340
  * Both default to a no-op when omitted, so existing callers/tests are
341
341
  * unaffected.
342
+ *
343
+ * `finalizeOwned` (optional, `(job) → boolean`) covers the dead-PID shape:
344
+ * a claude child exits BEFORE its spawnJob() has integrated the worktree
345
+ * branch and written the terminal row, so for that whole window the row is
346
+ * 'running' with a dead pid. Reaping it then races the fast-forward merge and
347
+ * parks landed work as reaped_without_integration (family-frame 137,
348
+ * 2026-10-10: park + merge in the same second). A true return skips the row
349
+ * with a warning; the caller bounds how long it may keep answering true.
342
350
  */
343
351
  /**
344
352
  * Pure, never-throws formatter for the dispatch-phase breadcrumb appended to
@@ -400,7 +408,7 @@ function resolvePidlessFailureOverride(job) {
400
408
  }
401
409
 
402
410
  function selectReapableJobs(jobs, now, {
403
- pidAlive, grace, findLiveProcess, getLogMtimeMs, getLogPid, logFreshWindowMs,
411
+ pidAlive, grace, findLiveProcess, getLogMtimeMs, getLogPid, logFreshWindowMs, finalizeOwned,
404
412
  } = {}) {
405
413
  const reapable = [];
406
414
  const warnings = [];
@@ -410,6 +418,10 @@ function selectReapableJobs(jobs, now, {
410
418
  const pid = j.runtime?.pid;
411
419
  if (pid) {
412
420
  if (pidAlive(pid)) continue;
421
+ if (typeof finalizeOwned === 'function' && finalizeOwned(j)) {
422
+ warnings.push({ slug: j.slug, reason: `pid ${pid} exited but its spawnJob is still finalizing (integration in flight)` });
423
+ continue;
424
+ }
413
425
  reapable.push({ slug: j.slug, pid, pidless: false });
414
426
  continue;
415
427
  }