fraim-hub 2.0.311 → 2.0.312

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.
@@ -2501,15 +2501,13 @@ class CliHostRuntime {
2501
2501
  return isEmployeeDetectionRefreshing();
2502
2502
  }
2503
2503
  startRun(hostId, projectPath, message, handlers, sessionId, launchContext) {
2504
- // R11: start/startDirect mint sessions rather than resuming one, so they
2505
- // stay outside the continuation queue entirely.
2506
- return this.spawn(hostId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env), projectPath, handlers);
2504
+ return this.spawnStartAndRegister(hostId, projectPath, handlers, sessionId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env));
2507
2505
  }
2508
2506
  continueRun(hostId, projectPath, sessionId, message, handlers, launchContext, deliveryIntent) {
2509
2507
  return this.guardedContinue(hostId, sessionId, { projectPath, message, handlers, launchContext, deliveryIntent });
2510
2508
  }
2511
2509
  startDirectRun(hostId, message, projectPath, handlers, sessionId, launchContext) {
2512
- return this.spawn(hostId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildDirectStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env), projectPath, handlers);
2510
+ return this.spawnStartAndRegister(hostId, projectPath, handlers, sessionId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildDirectStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env));
2513
2511
  }
2514
2512
  continueDirectRun(hostId, sessionId, message, projectPath, handlers, launchContext, deliveryIntent) {
2515
2513
  // R10: continueDirectRun is protected by the same per-hostId::sessionId
@@ -2538,7 +2536,7 @@ class CliHostRuntime {
2538
2536
  if (active.escalationTimer != null)
2539
2537
  clearTimeout(active.escalationTimer);
2540
2538
  active.pending.splice(0);
2541
- this.activeContinueRuns.delete(key);
2539
+ this.clearActiveRun(active);
2542
2540
  if (active.child.pid == null)
2543
2541
  return false;
2544
2542
  try {
@@ -2559,7 +2557,7 @@ class CliHostRuntime {
2559
2557
  const key = `${hostId}::${sessionId}`;
2560
2558
  const active = this.activeContinueRuns.get(key);
2561
2559
  if (!active) {
2562
- return this.spawnAndRegister(key, hostId, sessionId, entry);
2560
+ return this.spawnAndRegister(hostId, sessionId, entry);
2563
2561
  }
2564
2562
  // R3/R31: an ordinary follow-up queues behind existing work; a course
2565
2563
  // correction (deliveryIntent 'stop') jumps to the front of the queue and
@@ -2579,11 +2577,49 @@ class CliHostRuntime {
2579
2577
  // through `entry.handlers` once it is actually dequeued and spawned.
2580
2578
  return active.child;
2581
2579
  }
2582
- spawnAndRegister(key, hostId, sessionId, entry) {
2580
+ spawnStartAndRegister(hostId, projectPath, handlers, sessionId, plan) {
2581
+ const deferredSessionIds = [];
2582
+ let runEntry = null;
2583
+ const registerSessionId = (value) => {
2584
+ const discoveredSessionId = typeof value === 'string' ? value.trim() : '';
2585
+ if (!discoveredSessionId)
2586
+ return;
2587
+ if (!runEntry) {
2588
+ deferredSessionIds.push(discoveredSessionId);
2589
+ return;
2590
+ }
2591
+ this.registerActiveRun(hostId, discoveredSessionId, runEntry);
2592
+ };
2593
+ const wrappedHandlers = {
2594
+ ...handlers,
2595
+ onEvent: (event, channel) => {
2596
+ registerSessionId(event.sessionId);
2597
+ handlers.onEvent(event, channel);
2598
+ },
2599
+ };
2600
+ const child = this.spawn(hostId, plan, projectPath, wrappedHandlers);
2601
+ runEntry = { child, pending: [], handlers: wrappedHandlers };
2602
+ if (sessionId)
2603
+ registerSessionId(sessionId);
2604
+ for (const discoveredSessionId of deferredSessionIds) {
2605
+ registerSessionId(discoveredSessionId);
2606
+ }
2607
+ child.once('close', () => {
2608
+ if (!runEntry)
2609
+ return;
2610
+ if (runEntry.escalationTimer != null) {
2611
+ clearTimeout(runEntry.escalationTimer);
2612
+ runEntry.escalationTimer = undefined;
2613
+ }
2614
+ this.dequeueNext(hostId, runEntry);
2615
+ });
2616
+ return child;
2617
+ }
2618
+ spawnAndRegister(hostId, sessionId, entry) {
2583
2619
  const plan = (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(entry.direct ? buildDirectContinuePlan(hostId, sessionId, entry.message) : buildContinuePlan(hostId, sessionId, entry.message), entry.launchContext?.agent, entry.launchContext?.env);
2584
2620
  const child = this.spawn(hostId, plan, entry.projectPath, entry.handlers);
2585
2621
  const runEntry = { child, pending: [], handlers: entry.handlers };
2586
- this.activeContinueRuns.set(key, runEntry);
2622
+ this.registerActiveRun(hostId, sessionId, runEntry);
2587
2623
  child.once('close', () => {
2588
2624
  // Issue #1570: a redirect kill that eventually takes (just slower than
2589
2625
  // the escalation window) must not leave its retry timer dangling past
@@ -2592,10 +2628,30 @@ class CliHostRuntime {
2592
2628
  clearTimeout(runEntry.escalationTimer);
2593
2629
  runEntry.escalationTimer = undefined;
2594
2630
  }
2595
- this.dequeueNext(key, hostId, sessionId, runEntry);
2631
+ this.dequeueNext(hostId, runEntry);
2596
2632
  });
2597
2633
  return child;
2598
2634
  }
2635
+ registerActiveRun(hostId, sessionId, runEntry) {
2636
+ const key = `${hostId}::${sessionId}`;
2637
+ const existing = this.activeContinueRuns.get(key);
2638
+ if (existing && existing !== runEntry)
2639
+ return;
2640
+ if (!runEntry.keys)
2641
+ runEntry.keys = new Set();
2642
+ runEntry.keys.add(key);
2643
+ runEntry.sessionId = sessionId;
2644
+ this.activeContinueRuns.set(key, runEntry);
2645
+ }
2646
+ clearActiveRun(runEntry) {
2647
+ const keys = runEntry.keys ? Array.from(runEntry.keys) : [];
2648
+ for (const key of keys) {
2649
+ if (this.activeContinueRuns.get(key) === runEntry) {
2650
+ this.activeContinueRuns.delete(key);
2651
+ }
2652
+ }
2653
+ runEntry.keys?.clear();
2654
+ }
2599
2655
  // Issue #1570 (Defect 1): the redirect kill was previously fire-and-forget
2600
2656
  // — `killTree` was called once, with no way to know whether it actually
2601
2657
  // took, and no ceiling on how long the queued correction could sit behind
@@ -2660,11 +2716,14 @@ class CliHostRuntime {
2660
2716
  // batch uses the LAST entry's projectPath/handlers/launchContext unless a
2661
2717
  // 'stop' entry is present, in which case that correction's context wins —
2662
2718
  // it is the manager's most recent, most urgent instruction.
2663
- dequeueNext(key, hostId, sessionId, runEntry) {
2664
- this.activeContinueRuns.delete(key);
2719
+ dequeueNext(hostId, runEntry) {
2720
+ const sessionId = runEntry.sessionId;
2721
+ this.clearActiveRun(runEntry);
2665
2722
  const batch = runEntry.pending.splice(0);
2666
2723
  if (!batch.length)
2667
2724
  return;
2725
+ if (!sessionId)
2726
+ return;
2668
2727
  const combinedMessage = batch.map((e) => e.message).join('\n\n');
2669
2728
  const primary = batch.find((e) => e.deliveryIntent === 'stop') ?? batch[batch.length - 1];
2670
2729
  try {
@@ -2674,7 +2733,7 @@ class CliHostRuntime {
2674
2733
  // R19: a synchronous spawn failure must not leave the queue permanently
2675
2734
  // stuck behind this key — clear it so the next continueRun call spawns
2676
2735
  // fresh instead of queueing behind a dead entry.
2677
- this.activeContinueRuns.delete(key);
2736
+ this.clearActiveRun(runEntry);
2678
2737
  }
2679
2738
  }
2680
2739
  }
@@ -67,6 +67,17 @@ class RestartRecoveryPolicy {
67
67
  if (['stopped', 'done', 'awaiting_review', 'awaiting_user'].includes(pauseReason)) {
68
68
  return { action: 'skip', reason: `pause_${pauseReason}` };
69
69
  }
70
+ // Issue #1634: `activeRunExists` is direct, in-process proof this run is not
71
+ // orphaned — it must outrank every "does this LOOK orphaned" heuristic below.
72
+ // A freshly-fired scheduled/webhook run legitimately has no sessionId yet (the
73
+ // host CLI hasn't reported one) for its entire lifetime if it is short-lived;
74
+ // checking `missing_session` first force-failed a run that was actively
75
+ // executing in this same process, the instant its status became observably
76
+ // 'running' (issue #1634's own fix for that visibility gap is what turned this
77
+ // from a narrow, mostly-unreachable window into a routinely-hit one).
78
+ if (options.activeRunExists) {
79
+ return { action: 'defer', reason: 'active_run_exists' };
80
+ }
70
81
  if (!conversation.sessionId || typeof conversation.sessionId !== 'string' || !conversation.sessionId.trim()) {
71
82
  return { action: 'skip', reason: 'missing_session' };
72
83
  }
@@ -81,9 +92,6 @@ class RestartRecoveryPolicy {
81
92
  if (conversation.reviewHandoff?.reviewRequired) {
82
93
  return { action: 'skip', reason: 'awaiting_review' };
83
94
  }
84
- if (options.activeRunExists) {
85
- return { action: 'defer', reason: 'active_run_exists' };
86
- }
87
95
  // Issue #1159: `activeRunExists` only sees this process's run registry, so it
88
96
  // cannot tell that a *different* live Hub owns this run. Two Hubs on one
89
97
  // machine is the normal case here: the desktop Hub plus any Hub a job starts
@@ -5010,6 +5010,9 @@ class AiHubServer {
5010
5010
  'from the Hub job catalog, not the per-run tracking UUID returned by get_fraim_job\'s "Job ID" field.',
5011
5011
  };
5012
5012
  }
5013
+ isAdhocPromptJobId(jobId) {
5014
+ return typeof jobId === 'string' && jobId.trim() === 'adhoc-prompt';
5015
+ }
5013
5016
  applySeekMentoringSignalToRun(run, signal) {
5014
5017
  // Issue #732: promote using the stable jobName slug, not the per-call UUID
5015
5018
  // jobId (resolveHubJob would never match a UUID, leaving a freeform run
@@ -7524,9 +7527,15 @@ class AiHubServer {
7524
7527
  // Issue #1477 R1/R2: reject a jobId that doesn't resolve to a known catalog entry
7525
7528
  // (e.g. the per-run tracking UUID from get_fraim_job, mistaken for this field in
7526
7529
  // the reported repro) before the deployment is ever persisted.
7527
- if (!this.resolveHubJob(resolvedProjectPath, jobId)) {
7530
+ const isAdhocPrompt = this.isAdhocPromptJobId(jobId);
7531
+ if (!isAdhocPrompt && !this.resolveHubJob(resolvedProjectPath, jobId)) {
7528
7532
  return res.status(400).json(this.invalidScheduleJobIdError(jobId));
7529
7533
  }
7534
+ // Issue #1610 R3: adhoc-prompt has no fixed task — the instructions field is its
7535
+ // sole driver. An adhoc-prompt schedule with no instructions would silently do nothing.
7536
+ if (isAdhocPrompt && !(typeof instructions === 'string' && instructions.trim())) {
7537
+ return res.status(400).json({ error: 'Ad-hoc assignments require a non-empty instructions field.' });
7538
+ }
7530
7539
  const normalizedConversationId = typeof conversationId === 'string' && conversationId.trim()
7531
7540
  ? conversationId.trim()
7532
7541
  : undefined;
@@ -7626,9 +7635,19 @@ class AiHubServer {
7626
7635
  return res.status(404).json({ error: 'Deployment not found.' });
7627
7636
  // Issue #1477 R3: same catalog validation as create, applied only when jobId is
7628
7637
  // actually part of this update.
7629
- if (jobId !== undefined && !this.resolveHubJob(resolvedProjectPath !== undefined ? resolvedProjectPath : existing.projectPath, jobId)) {
7638
+ if (jobId !== undefined && !this.isAdhocPromptJobId(jobId) && !this.resolveHubJob(resolvedProjectPath !== undefined ? resolvedProjectPath : existing.projectPath, jobId)) {
7630
7639
  return res.status(400).json(this.invalidScheduleJobIdError(jobId));
7631
7640
  }
7641
+ // Issue #1610 R4: guard the same invariant as POST — an adhoc-prompt deployment
7642
+ // must always have non-empty instructions. Compute the effective post-update values
7643
+ // before writing so we can reject before any state change.
7644
+ const effectiveJobId = jobId !== undefined ? jobId : existing.jobId;
7645
+ const effectiveInstructions = instructions !== undefined
7646
+ ? (typeof instructions === 'string' ? instructions.trim() : undefined)
7647
+ : existing.instructions;
7648
+ if (effectiveJobId === 'adhoc-prompt' && !effectiveInstructions) {
7649
+ return res.status(400).json({ error: 'Ad-hoc assignments require a non-empty instructions field.' });
7650
+ }
7632
7651
  const nextHostId = hostId !== undefined && validHosts.includes(hostId) ? hostId : existing.hostId;
7633
7652
  const nextConfiguredAgentId = configuredAgentId !== undefined
7634
7653
  ? (typeof configuredAgentId === 'string' && configuredAgentId.trim() ? configuredAgentId.trim() : undefined)
@@ -8382,6 +8401,13 @@ class AiHubServer {
8382
8401
  // can call runRegistry.update without "Run not found" throws.
8383
8402
  this.runRegistry.create(run, {});
8384
8403
  this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = run.id; });
8404
+ // Issue #1634 (Defect B): persist status: 'running' synchronously, before the
8405
+ // host process is launched. Without this, the persisted conversation is only
8406
+ // ever written on the first stream event (onEvent) or at exit (onExit) — a
8407
+ // job fast enough to finish before its first stream event lands can go
8408
+ // straight from one 'completed' state to the next, with 'running' never
8409
+ // observably written for any poll to find.
8410
+ this.persistRunConversation(run, run.conversationId || run.id);
8385
8411
  const handlers = {
8386
8412
  onEvent: (event, channel) => {
8387
8413
  this.runRegistry.update(run.id, (current) => {
@@ -8404,19 +8430,17 @@ class AiHubServer {
8404
8430
  if (updated)
8405
8431
  this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
8406
8432
  },
8407
- onExit: (exitCode) => {
8408
- this.runRegistry.update(run.id, (r) => {
8409
- r.exitCode = exitCode;
8410
- r.status = exitCode === 0 ? 'completed' : 'failed';
8411
- r.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.${hostErrorSuffix(r)}`));
8412
- pushTerminalFailureToThread(r, exitCode);
8413
- });
8414
- const updated = this.runRegistry.get(run.id);
8415
- if (updated)
8416
- this.persistRunConversation(updated, updated.conversationId || updated.id);
8433
+ // Issue #1634 (Defect A): route through the shared handleRunExit()/classifyExit()
8434
+ // chokepoint (issue #904), matching every other run-exit call site in this file
8435
+ // (server.ts:4678, 4992, 7396, 7632, 7726, 9315 as of this change). The previous
8436
+ // bespoke handler below set status/exitCode directly and never called
8437
+ // classifyExit(), so a deployment-triggered exit's pauseReason was never computed
8438
+ // — the recurringPark latch set mid-run (via the shared onEvent -> recordHostEvent
8439
+ // path above) was silently discarded, and a compaction/background-task exit was
8440
+ // force-terminated instead of auto-continuing like a manager-started run.
8441
+ onExit: (exitCode) => this.handleRunExit(run.id, exitCode, () => {
8417
8442
  this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
8418
- this.runRegistry.dispose(run.id);
8419
- },
8443
+ }),
8420
8444
  };
8421
8445
  const childLaunchContext = this.withHubRunIdEnv(launchContext, run.id);
8422
8446
  const child = existingHostSession
@@ -72,7 +72,7 @@ ${buildDeferredToolBootstrapSection(profile)}1. **Confirm FRAIM activation**:
72
72
  If local FRAIM job stubs are present in the workspace, inspect those first and match the request locally. Also inspect \`fraim/personalized-employee/jobs/\` for local overrides or repo-specific jobs. If local files are missing or you cannot inspect workspace files, call \`list_fraim_jobs()\` to view the full catalog, including any proxy-discoverable personalized jobs.
73
73
 
74
74
  3. **Find the match**:
75
- If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, say that no FRAIM job matches and continue with normal tools or ask one concise clarification. Do not pick the nearest catalog job.
75
+ If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, ask once: "No catalog job matches. Would you like to run this as an ad-hoc task?" On confirmation, call \`get_fraim_job({ job: "adhoc-prompt" })\` and execute it with the user's instructions as the task input do not pick the nearest catalog job. Do not ask again if the user already provided instructions.
76
76
 
77
77
  4. **Load the full content**:
78
78
  - For jobs, call \`get_fraim_job({ job: "<matched-job-name>" })\`.
@@ -125,6 +125,10 @@ function ensureUserLevelDependencies(userFraimDir) {
125
125
  if (missing.length === 0) {
126
126
  return;
127
127
  }
128
+ if (process.env.FRAIM_SKIP_USER_LEVEL_DEP_INSTALL === '1') {
129
+ console.log(chalk_1.default.yellow(`TEST_MODE: skipping user-level runtime dependency install (${missing.join(', ')}).`));
130
+ return;
131
+ }
128
132
  console.log(chalk_1.default.blue(`📦 Installing user-level runtime dependencies (${missing.join(', ')})...`));
129
133
  try {
130
134
  (0, child_process_1.execSync)('npm install --no-audit --no-fund --no-save --no-package-lock --omit=dev', {
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.PERSONA_CAPABILITY_BUNDLES = exports.FREE_JOBS = exports.GENERIC_WORKER_PERSONA_KEY = void 0;
3
+ exports.UNOWNED_EXEMPT_JOBS = exports.PERSONA_CAPABILITY_BUNDLES = exports.FREE_JOBS = exports.GENERIC_WORKER_PERSONA_KEY = void 0;
4
4
  exports.isFreeJob = isFreeJob;
5
5
  exports.getPersonaCapabilityBundle = getPersonaCapabilityBundle;
6
6
  exports.getProtectedPersonaForJob = getProtectedPersonaForJob;
@@ -315,6 +315,17 @@ const GENERIC_WORKER_OWNED_JOBS = new Set([
315
315
  'organization-onboarding',
316
316
  'organizational-learning-synthesis',
317
317
  ]);
318
+ // Jobs intentionally left without a named persona owner. They resolve to null
319
+ // from getProtectedPersonaForJob (runs ungated; Hub attributes them to the
320
+ // DEFAULT_UNASSIGNED_PERSONA_KEY/MANdy — same behavior as today's adhoc runs).
321
+ // validate-job-ownership exempts these from the "every job must have an owner"
322
+ // assertion so the validator still catches accidentally unowned jobs.
323
+ //
324
+ // Issue #1610: adhoc-prompt is a manager-directed fallback job, not tied to any
325
+ // specialist hire, matching the existing no-employee attribution of adhoc runs.
326
+ exports.UNOWNED_EXEMPT_JOBS = new Set([
327
+ 'adhoc-prompt',
328
+ ]);
318
329
  function getPersonaCapabilityBundle(personaKey) {
319
330
  return exports.PERSONA_CAPABILITY_BUNDLES[personaKey];
320
331
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.311",
3
+ "version": "2.0.312",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "author": "Sid Mathur <sid.mathur@gmail.com>",
6
6
  "homepage": "https://github.com/mathursrus/FRAIM#readme",
@@ -211,7 +211,7 @@
211
211
  "electron-updater": "^6.8.9",
212
212
  "express": "^5.2.1",
213
213
  "extract-zip": "^2.0.1",
214
- "fraim": "2.0.311",
214
+ "fraim": "2.0.312",
215
215
  "mongodb": "^7.0.0",
216
216
  "node-cron": "4.2.1",
217
217
  "node-edge-tts": "^1.2.10",
@@ -1145,7 +1145,7 @@
1145
1145
  <p class="dep-trig-note">Runs when an external system POSTs to the inbound URL, generated after you add the assignment.</p>
1146
1146
  </div>
1147
1147
  <div class="hm-field">
1148
- <label for="dep-instructions">Instructions <span class="dep-optional">(optional)</span></label>
1148
+ <label for="dep-instructions">Instructions <span class="dep-optional" id="dep-inst-optional-label">(optional)</span></label>
1149
1149
  <textarea id="dep-instructions" rows="2" placeholder="Optional message sent to the agent at the start of each run"></textarea>
1150
1150
  </div>
1151
1151
  <div class="dep-modal-actions">
@@ -1234,7 +1234,17 @@ const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'del
1234
1234
  // It is listed in CLIENT_ONLY_CONV_FIELDS so `cleanConversationHeader()` strips
1235
1235
  // any inbound value. That is what preserves issue #913: a marker arriving over
1236
1236
  // the wire is never trusted, only one this client set after a real fetch.
1237
- const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_bodyFetched', '_stopping'];
1237
+ //
1238
+ // Issue #1634: `_bodyFetchedRunId`/`_bodyFetchedLastUpdatedAt` record the
1239
+ // runId/lastUpdatedAt that were true at the moment THIS CLIENT's currently-held
1240
+ // message/event content was last made current (a full body fetch, or a live-run
1241
+ // poll fold — see mergeConversationBody/foldRunIntoConversation). The 30s header
1242
+ // poll (bgRefreshConversations/hydrateProjectConversationHeaders) merges fresh
1243
+ // runId/lastUpdatedAt directly into the SAME conversation object findConversation()
1244
+ // returns — there is no separate header copy — so comparing those fields against
1245
+ // this fetch-time snapshot is what lets conversationBodyIsStale() detect that a
1246
+ // scheduled/webhook fire replaced the run out from under an already-open tab.
1247
+ const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_bodyFetched', '_bodyFetchedRunId', '_bodyFetchedLastUpdatedAt', '_stopping'];
1238
1248
  function slimConversationForPersist(conv) {
1239
1249
  if (!conv || typeof conv !== 'object') return conv;
1240
1250
  const slim = { ...conv };
@@ -1310,9 +1320,30 @@ function mergeConversationBody(existing, body) {
1310
1320
  // Issue #1090: the body request came back, so this conversation is hydrated
1311
1321
  // regardless of which fields the response happened to contain.
1312
1322
  merged._bodyFetched = true;
1323
+ // Issue #1634: stamp the runId/lastUpdatedAt this fetch reflects, so a later
1324
+ // header-poll-observed change can be detected as staleness (see
1325
+ // conversationBodyIsStale / CLIENT_ONLY_CONV_FIELDS comment above).
1326
+ merged._bodyFetchedRunId = body.runId;
1327
+ merged._bodyFetchedLastUpdatedAt = body.lastUpdatedAt;
1313
1328
  return merged;
1314
1329
  }
1315
1330
 
1331
+ // Issue #1634 (Defect C): `conversationHasBody()` only answers "was a body ever
1332
+ // fetched" — true forever after the first fetch (issue #1090), even once a
1333
+ // scheduled/webhook fire replaces the run underneath an already-open tab. This
1334
+ // answers the separate question "is the body we're holding still current",
1335
+ // by comparing the runId/lastUpdatedAt in effect when we last made the body
1336
+ // current (mergeConversationBody / foldRunIntoConversation) against the same
1337
+ // object's own runId/lastUpdatedAt fields, which the 30s header poll keeps
1338
+ // fresh in place (mergeConversationHeader merges into the SAME object
1339
+ // findConversation() returns — there is no separate header copy to compare
1340
+ // against).
1341
+ function conversationBodyIsStale(existing) {
1342
+ if (!existing || existing._bodyFetchedRunId === undefined) return false;
1343
+ if (existing.runId !== existing._bodyFetchedRunId) return true;
1344
+ return timestampMillis(existing.lastUpdatedAt) > timestampMillis(existing._bodyFetchedLastUpdatedAt);
1345
+ }
1346
+
1316
1347
  function mergeConversationListHeaders(existingList, headers) {
1317
1348
  const existingById = new Map((existingList || []).map((conv) => [conv.id, conv]));
1318
1349
  return (headers || []).map((header) => mergeConversationHeader(existingById.get(header.id), header));
@@ -1346,7 +1377,7 @@ function clearConversationBodyPendingFor(conv, fallbackProjectPath, id) {
1346
1377
  async function hydrateConversationBody(projectPath, id, options = {}) {
1347
1378
  if (!id) return null;
1348
1379
  const existing = findConversation(id);
1349
- if (!options.force && conversationHasBody(existing)) {
1380
+ if (!options.force && conversationHasBody(existing) && !conversationBodyIsStale(existing)) {
1350
1381
  clearConversationBodyPendingFor(existing, projectPath, id);
1351
1382
  return existing;
1352
1383
  }
@@ -1361,6 +1392,24 @@ async function hydrateConversationBody(projectPath, id, options = {}) {
1361
1392
  if (!body) return null;
1362
1393
  normalizeGeminiConversationMessages(body);
1363
1394
  const current = findConversation(body.id);
1395
+ // A body hydrate can be in flight while a live run poll or continue POST
1396
+ // folds a newer run snapshot into the same conversation. Do not let the
1397
+ // slower, older body response replace fresh messages/events with the
1398
+ // persisted copy it started from.
1399
+ const currentUpdatedAt = timestampMillis(current && current.lastUpdatedAt);
1400
+ const bodyUpdatedAt = timestampMillis(body.lastUpdatedAt);
1401
+ if (
1402
+ !options.force
1403
+ && current
1404
+ && current.runId === body.runId
1405
+ && conversationHasBody(current)
1406
+ && currentUpdatedAt
1407
+ && bodyUpdatedAt
1408
+ && currentUpdatedAt > bodyUpdatedAt
1409
+ ) {
1410
+ clearConversationBodyPendingFor(current, projectPath, current.id);
1411
+ return current;
1412
+ }
1364
1413
  const merged = mergeConversationBody(current, body);
1365
1414
  const bucket = convBucketKey(merged);
1366
1415
  const list = (state.conversations[bucket] || []).slice();
@@ -1398,7 +1447,15 @@ async function rehydrateConversationAfterRunNotFound(conv) {
1398
1447
 
1399
1448
  function ensureActiveConversationBody() {
1400
1449
  const conv = activeConversation();
1401
- if (!conv || conversationHasBody(conv)) return;
1450
+ if (!conv) return;
1451
+ // Issue #1634: this own early-return used to check only conversationHasBody(),
1452
+ // which short-circuits BEFORE hydrateConversationBody()'s own staleness check
1453
+ // ever runs — the exact call path the 30s background poll uses for an
1454
+ // already-open, never-switched-away-from conversation (bgRefreshConversations
1455
+ // -> ensureActiveConversationBody). Without this, a scheduled/webhook fire
1456
+ // against the tab a manager is actively looking at would never be picked up
1457
+ // by that poll at all, regardless of the hydrateConversationBody fix below.
1458
+ if (conversationHasBody(conv) && !conversationBodyIsStale(conv)) return;
1402
1459
  const bucket = convBucketKey(conv);
1403
1460
  state.conversationBodiesPending = bucket;
1404
1461
  hydrateConversationBody(bucket, conv.id).catch((error) =>
@@ -3833,6 +3890,9 @@ function renderActive() {
3833
3890
  pendingAnchorShownAt = null;
3834
3891
  pendingAnchorGraceActiveLastTick = false;
3835
3892
  userScrolledAwayDuringPendingAnchor = false;
3893
+ pendingAnchorForceStartedAt = 0;
3894
+ window.__fraimPendingAnchorUserScroll = false;
3895
+ delete els['messages'].dataset.pendingAnchorUserScroll;
3836
3896
  // A pending coaching job is scoped to the active conversation — discard it
3837
3897
  // whenever the user switches to a different conversation.
3838
3898
  clearPendingCoachingJob();
@@ -3853,7 +3913,14 @@ function renderActive() {
3853
3913
  // existing rows don't re-animate. If for some reason the data shrunk
3854
3914
  // (server revoked a message), fall back to a full re-render.
3855
3915
  const bodyReady = conversationHasBody(conv);
3856
- if (!bodyReady) ensureActiveConversationBody();
3916
+ // Issue #1634: call unconditionally, not just when the body was never
3917
+ // fetched. ensureActiveConversationBody() now does its own staleness check
3918
+ // internally (conversationBodyIsStale) and is a cheap no-op when the body is
3919
+ // both present and current; this is the render-path trigger that actually
3920
+ // fires on every poll-driven re-render for the active conversation, so the
3921
+ // fix only reaches this call site if it isn't gated behind the old
3922
+ // conversationHasBody()-only condition.
3923
+ ensureActiveConversationBody();
3857
3924
  const messages = bodyReady ? (conv.messages || []) : [];
3858
3925
  // Issue #820: while the two-phase hydrate is still fetching bodies, show a loading indicator
3859
3926
  // for the active conversation instead of an empty transcript. A dedicated node (kept separate
@@ -3913,6 +3980,14 @@ function renderActive() {
3913
3980
  // genuinely new messages, not every row a messagesMutated full rebuild just
3914
3981
  // re-appended (e.g. an existing message's badge disappearing).
3915
3982
  const priorRenderedMessageCount = renderedMessageCount;
3983
+ const hadPendingRowsBeforeRebuild = !!els['messages'].querySelector('.message[data-pending="true"]');
3984
+ if (
3985
+ hadPendingRowsBeforeRebuild
3986
+ && els['messages'].scrollTop <= 20
3987
+ && els['messages'].scrollHeight - els['messages'].clientHeight > 80
3988
+ ) {
3989
+ userScrolledAwayDuringPendingAnchor = true;
3990
+ }
3916
3991
  if (messages.length < renderedMessageCount || messagesMutated) {
3917
3992
  els['messages'].innerHTML = '';
3918
3993
  renderedMessageCount = 0;
@@ -5520,7 +5595,7 @@ function pendingBacklogScrollTop(host) {
5520
5595
  if (!firstPending) return host.scrollHeight;
5521
5596
  const backlogTop = firstPending.getBoundingClientRect().top - host.getBoundingClientRect().top + host.scrollTop;
5522
5597
  const maxScrollTop = host.scrollHeight - host.clientHeight;
5523
- return Math.max(0, Math.min(backlogTop, maxScrollTop));
5598
+ return Math.max(0, Math.min(backlogTop - 16, maxScrollTop));
5524
5599
  }
5525
5600
 
5526
5601
  // Issue #1570: the #1249 anchor above never released. Once nearBottom went
@@ -5539,6 +5614,8 @@ let pendingAnchorShownAt = null;
5539
5614
  let pendingAnchorGraceActiveLastTick = false;
5540
5615
  let pendingAnchorScrollListenerHost = null;
5541
5616
  let userScrolledAwayDuringPendingAnchor = false;
5617
+ let pendingAnchorProgrammaticScrollUntil = 0;
5618
+ let pendingAnchorForceStartedAt = 0;
5542
5619
 
5543
5620
  // A manual scroll away from the anchor must be detected from a real user
5544
5621
  // gesture, not from the 'scroll' event a programmatic assignment ALSO fires —
@@ -5556,10 +5633,32 @@ let userScrolledAwayDuringPendingAnchor = false;
5556
5633
  function ensurePendingAnchorScrollListener(host) {
5557
5634
  if (pendingAnchorScrollListenerHost === host) return;
5558
5635
  pendingAnchorScrollListenerHost = host;
5559
- const markUserScroll = () => { userScrolledAwayDuringPendingAnchor = true; };
5560
- host.addEventListener('wheel', markUserScroll, { passive: true });
5561
- host.addEventListener('touchmove', markUserScroll, { passive: true });
5636
+ const markUserScroll = () => {
5637
+ userScrolledAwayDuringPendingAnchor = true;
5638
+ host.dataset.pendingAnchorUserScroll = 'true';
5639
+ window.__fraimPendingAnchorUserScroll = true;
5640
+ };
5641
+ host.addEventListener('wheel', markUserScroll, { passive: true, capture: true });
5642
+ host.addEventListener('touchmove', markUserScroll, { passive: true, capture: true });
5643
+ window.addEventListener('wheel', markUserScroll, { passive: true, capture: true });
5644
+ window.addEventListener('touchmove', markUserScroll, { passive: true, capture: true });
5562
5645
  host.addEventListener('mousedown', (event) => { if (event.target === host) markUserScroll(); });
5646
+ host.addEventListener('scroll', () => {
5647
+ if (pendingAnchorShownAt === null) return;
5648
+ if (Date.now() <= pendingAnchorProgrammaticScrollUntil) return;
5649
+ const pendingRow = host.querySelector('.message[data-pending="true"]');
5650
+ if (!pendingRow) return;
5651
+ if (host.scrollTop <= 20 && host.scrollHeight - host.clientHeight > 80) markUserScroll();
5652
+ }, { passive: true });
5653
+ }
5654
+
5655
+ function setThreadScrollTop(host, top) {
5656
+ pendingAnchorProgrammaticScrollUntil = Date.now() + 200;
5657
+ host.scrollTop = top;
5658
+ }
5659
+
5660
+ function pendingAnchorUserScrolled(host) {
5661
+ return userScrolledAwayDuringPendingAnchor || host.dataset.pendingAnchorUserScroll === 'true' || window.__fraimPendingAnchorUserScroll === true;
5563
5662
  }
5564
5663
 
5565
5664
  function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom) {
@@ -5569,19 +5668,56 @@ function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom)
5569
5668
  if (!host) return;
5570
5669
  if (latest.status === 'running') {
5571
5670
  ensurePendingAnchorScrollListener(host);
5671
+ if (!forceBottom && pendingAnchorUserScrolled(host)) {
5672
+ host.scrollTop = 0;
5673
+ return;
5674
+ }
5572
5675
  const pendingRow = host.querySelector('.message[data-pending="true"]');
5573
5676
  // forceBottom is true exactly when a NEW pending (queued/redirecting)
5574
5677
  // message just arrived (see the caller's hasNewPendingDeliveryMessage) —
5575
5678
  // that's the moment to (re)start the grace window and trust the manager's
5576
5679
  // scroll position again, not every tick a pending row happens to exist.
5577
5680
  if (forceBottom) {
5578
- pendingAnchorShownAt = Date.now();
5579
- userScrolledAwayDuringPendingAnchor = false;
5681
+ const now = Date.now();
5682
+ const deferredSameForce = pendingAnchorForceStartedAt > 0 && (now - pendingAnchorForceStartedAt) < 250;
5683
+ if (
5684
+ deferredSameForce
5685
+ && (
5686
+ pendingAnchorUserScrolled(host)
5687
+ || (
5688
+ now > pendingAnchorProgrammaticScrollUntil
5689
+ && host.scrollTop <= 20
5690
+ && host.scrollHeight - host.clientHeight > 80
5691
+ )
5692
+ )
5693
+ ) {
5694
+ userScrolledAwayDuringPendingAnchor = true;
5695
+ host.dataset.pendingAnchorUserScroll = 'true';
5696
+ window.__fraimPendingAnchorUserScroll = true;
5697
+ } else if (!deferredSameForce) {
5698
+ userScrolledAwayDuringPendingAnchor = false;
5699
+ delete host.dataset.pendingAnchorUserScroll;
5700
+ window.__fraimPendingAnchorUserScroll = false;
5701
+ pendingAnchorForceStartedAt = now;
5702
+ }
5703
+ pendingAnchorShownAt = now;
5580
5704
  } else if (!pendingRow) {
5581
5705
  pendingAnchorShownAt = null;
5582
5706
  }
5583
5707
  const anchorGraceActive = !!pendingRow && pendingAnchorShownAt !== null &&
5584
5708
  (Date.now() - pendingAnchorShownAt) < PENDING_ANCHOR_GRACE_MS;
5709
+ if (
5710
+ pendingRow
5711
+ && pendingAnchorShownAt !== null
5712
+ && !forceBottom
5713
+ && Date.now() > pendingAnchorProgrammaticScrollUntil
5714
+ && host.scrollTop <= 20
5715
+ && host.scrollHeight - host.clientHeight > 80
5716
+ ) {
5717
+ userScrolledAwayDuringPendingAnchor = true;
5718
+ host.dataset.pendingAnchorUserScroll = 'true';
5719
+ window.__fraimPendingAnchorUserScroll = true;
5720
+ }
5585
5721
  // Fires exactly once, on the tick where the anchor stops being active —
5586
5722
  // either the pending badge just cleared, or the grace window for a
5587
5723
  // still-unresolved pending row (e.g. Defect 1's kill still in flight)
@@ -5595,12 +5731,22 @@ function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom)
5595
5731
  // #936: re-evaluate nearBottom at call time so deferred invocations respect
5596
5732
  // any scroll the user made between the render tick and this callback.
5597
5733
  const nearBottom = host.scrollHeight - host.scrollTop - host.clientHeight < 80;
5598
- if (catchUpToBottom && userScrolledAwayDuringPendingAnchor) return;
5734
+ if (catchUpToBottom && host.scrollTop <= 20 && host.scrollHeight - host.clientHeight > 80) {
5735
+ userScrolledAwayDuringPendingAnchor = true;
5736
+ host.dataset.pendingAnchorUserScroll = 'true';
5737
+ window.__fraimPendingAnchorUserScroll = true;
5738
+ }
5739
+ if (catchUpToBottom && pendingAnchorUserScrolled(host)) return;
5740
+ if (forceBottom && pendingAnchorUserScrolled(host)) return;
5741
+ if (anchorGraceActive && !pendingAnchorUserScrolled(host)) {
5742
+ setThreadScrollTop(host, pendingBacklogScrollTop(host));
5743
+ return;
5744
+ }
5599
5745
  // Issue #1249: a message the manager just sent while the agent was
5600
5746
  // mid-turn (queued or redirecting) must be visible without the manager
5601
5747
  // having to scroll, even if they had scrolled up to review history.
5602
5748
  if (nearBottom || forceBottom || catchUpToBottom) {
5603
- host.scrollTop = anchorGraceActive ? pendingBacklogScrollTop(host) : host.scrollHeight;
5749
+ setThreadScrollTop(host, anchorGraceActive ? pendingBacklogScrollTop(host) : host.scrollHeight);
5604
5750
  }
5605
5751
  return;
5606
5752
  } else if (shouldScrollForUpdate) {
@@ -7593,13 +7739,21 @@ function renderCpRows(searchText) {
7593
7739
  }
7594
7740
  }
7595
7741
 
7596
- // Build flat row list: recent first, then catalog jobs, then teach entries
7597
- // last. Teach entries go after the catalog jobs so the first runnable job
7598
- // stays the default keyboard selection (ArrowDown+Enter runs a job, not a
7599
- // teach flow). The flat order must match the render order below so
7742
+ // Issue #1610 R13: pinned "Ad-hoc" row in the catalog list when a non-empty
7743
+ // search yields zero catalog matches. Clicking calls startAdhoc with the
7744
+ // current search text so the user's query seeds the freeform instructions.
7745
+ const adhocRows = q && catalogJobs.length === 0
7746
+ ? [{ type: 'adhoc', job: { id: 'adhoc-prompt', title: 'Ad-hoc', intent: 'run as custom instructions' }, instructions: '' }]
7747
+ : [];
7748
+
7749
+ // Build flat row list: recent first, then adhoc (if shown), then catalog jobs,
7750
+ // then teach entries last. Teach entries go after the catalog jobs so the first
7751
+ // runnable job stays the default keyboard selection (ArrowDown+Enter runs a job,
7752
+ // not a teach flow). The flat order must match the render order below so
7600
7753
  // click/keyboard indices line up.
7601
7754
  state.cpRows = [
7602
7755
  ...recentRows,
7756
+ ...adhocRows,
7603
7757
  ...catalogJobs.map((j) => ({ type: 'job', job: j, instructions: '' })),
7604
7758
  ...teachRows,
7605
7759
  ];
@@ -7616,16 +7770,20 @@ function renderCpRows(searchText) {
7616
7770
  });
7617
7771
  }
7618
7772
 
7619
- // Render catalog section: catalog jobs first, then teach entries last.
7620
- // flatIndex continues from recentRows so it matches state.cpRows order.
7773
+ // Render catalog section: adhoc pinned row first (when shown), then catalog
7774
+ // jobs, then teach entries last. flatIndex continues from recentRows so it
7775
+ // matches state.cpRows order.
7621
7776
  const catalogList = document.getElementById('cp-catalog-list');
7622
7777
  if (catalogList) {
7623
7778
  catalogList.innerHTML = '';
7779
+ adhocRows.forEach((row, i) => {
7780
+ catalogList.appendChild(buildCpRow(row, recentRows.length + i));
7781
+ });
7624
7782
  catalogJobs.forEach((job, i) => {
7625
- catalogList.appendChild(buildCpRow({ type: 'job', job, instructions: '' }, recentRows.length + i));
7783
+ catalogList.appendChild(buildCpRow({ type: 'job', job, instructions: '' }, recentRows.length + adhocRows.length + i));
7626
7784
  });
7627
7785
  teachRows.forEach((row, i) => {
7628
- catalogList.appendChild(buildCpRow(row, recentRows.length + catalogJobs.length + i));
7786
+ catalogList.appendChild(buildCpRow(row, recentRows.length + adhocRows.length + catalogJobs.length + i));
7629
7787
  });
7630
7788
  }
7631
7789
 
@@ -7684,7 +7842,7 @@ function buildCpRow(row, flatIndex) {
7684
7842
 
7685
7843
  const icon = document.createElement('span');
7686
7844
  icon.className = 'cp-row-icon';
7687
- icon.textContent = row.type === 'recent' ? '🕐' : row.type === 'teach' ? '🎓' : '📋';
7845
+ icon.textContent = row.type === 'recent' ? '🕐' : row.type === 'teach' ? '🎓' : row.type === 'adhoc' ? '✨' : '📋';
7688
7846
 
7689
7847
  const body = document.createElement('span');
7690
7848
  body.className = 'cp-row-body';
@@ -7711,7 +7869,7 @@ function buildCpRow(row, flatIndex) {
7711
7869
  // #1340: this is the actual "+ Delegate Job" catalog (openModal → openPalette →
7712
7870
  // renderCpRows → buildCpRow) — the row never had a visualize affordance at all.
7713
7871
  // Teach rows carry a synthetic, non-catalog job object, so skip them.
7714
- if (row.type !== 'teach') {
7872
+ if (row.type !== 'teach' && row.type !== 'adhoc') {
7715
7873
  el.appendChild(tfCreateJobVizControl(row.job));
7716
7874
  }
7717
7875
  if (row.job.requiredPersonaKey) {
@@ -7745,6 +7903,15 @@ function renderCpHighlight() {
7745
7903
  function selectCpRow(index) {
7746
7904
  const row = state.cpRows[index];
7747
7905
  if (!row) return;
7906
+ // Issue #1610 R13: adhoc row launches freeform directly with the current search text.
7907
+ // Close the palette first so the step-2 modal opens on a clean slate.
7908
+ if (row.type === 'adhoc') {
7909
+ const search = document.getElementById('cp-search');
7910
+ const searchText = search ? search.value.trim() : '';
7911
+ closePalette();
7912
+ startAdhoc(searchText);
7913
+ return;
7914
+ }
7748
7915
  state.cpSelectedJob = row.job;
7749
7916
  state.cpHighlightIndex = index;
7750
7917
  renderCpHighlight();
@@ -8808,6 +8975,12 @@ function foldRunIntoConversation(conv, run) {
8808
8975
  // can derive the correct pill without heuristics.
8809
8976
  if (run.pauseReason !== undefined) conv.pauseReason = run.pauseReason;
8810
8977
  conv.lastUpdatedAt = Date.now();
8978
+ // Issue #1634: this fold just made conv's messages/events current for run.id,
8979
+ // as of the lastUpdatedAt stamped above — record that so a subsequent
8980
+ // hydrateConversationBody() call (e.g. on switch-away/switch-back while this
8981
+ // same run is still live) sees the content as fresh and does not re-fetch.
8982
+ conv._bodyFetchedRunId = run.id;
8983
+ conv._bodyFetchedLastUpdatedAt = conv.lastUpdatedAt;
8811
8984
  }
8812
8985
 
8813
8986
  // Issue #442: fold the Direct (B) side run into the conversation's compareRun slot.
@@ -12870,6 +13043,21 @@ function buildDeploymentRow(dep) {
12870
13043
  title.className = 'dep-row-title';
12871
13044
  title.textContent = dep.label;
12872
13045
  body.appendChild(title);
13046
+ // Issue #1610 R10: for adhoc-prompt deployments show the job name (resolved from
13047
+ // catalog metadata) and the first 50 chars of instructions as a subtitle.
13048
+ if (dep.jobId === 'adhoc-prompt') {
13049
+ const depJobInfo = (state.bootstrap?.jobs ?? []).find((j) => j.id === dep.jobId);
13050
+ const jobLabel = document.createElement('span');
13051
+ jobLabel.className = 'dep-row-job-label';
13052
+ jobLabel.textContent = (depJobInfo && depJobInfo.title) || dep.jobId;
13053
+ body.appendChild(jobLabel);
13054
+ if (dep.instructions) {
13055
+ const sub = document.createElement('span');
13056
+ sub.className = 'dep-row-sub';
13057
+ sub.textContent = dep.instructions.slice(0, 50);
13058
+ body.appendChild(sub);
13059
+ }
13060
+ }
12873
13061
  const meta = document.createElement('span');
12874
13062
  meta.className = 'assign-row-meta';
12875
13063
  const badge = document.createElement('span');
@@ -13040,6 +13228,23 @@ function populateAgentSelect(sel, currentHostId) {
13040
13228
  if (pick) sel.value = pick;
13041
13229
  }
13042
13230
 
13231
+ // Issue #1610 R8: sync the instructions label text and placeholder when the job
13232
+ // selection changes. adhoc-prompt requires instructions; all other jobs treat
13233
+ // them as optional context.
13234
+ function updateDepInstructionsLabel() {
13235
+ const jobSel = document.getElementById('dep-job');
13236
+ const labelEl = document.getElementById('dep-inst-optional-label');
13237
+ const textarea = document.getElementById('dep-instructions');
13238
+ const isAdhoc = jobSel && jobSel.value === 'adhoc-prompt';
13239
+ if (labelEl) labelEl.textContent = isAdhoc ? '(required)' : '(optional)';
13240
+ if (textarea) {
13241
+ textarea.placeholder = isAdhoc
13242
+ ? 'Describe what to do each run…'
13243
+ : 'Optional message sent to the agent at the start of each run';
13244
+ textarea.setAttribute('aria-required', isAdhoc ? 'true' : 'false');
13245
+ }
13246
+ }
13247
+
13043
13248
  // #693 R2: open the single consolidated assignment modal. `dep` present = edit;
13044
13249
  // its .type locks the segmented control. New assignments default to Scheduled.
13045
13250
  function openDeploymentModal(dep) {
@@ -13049,13 +13254,24 @@ function openDeploymentModal(dep) {
13049
13254
 
13050
13255
  const jobSel = document.getElementById('dep-job');
13051
13256
  jobSel.innerHTML = '';
13052
- // Issue #1451: sort alphabetically by title so the New Assignment job list is scannable.
13053
- for (const j of sortJobsByTitle(jobs)) {
13257
+ const catalogJobs = jobs.filter((j) => j && j.id !== 'adhoc-prompt');
13258
+ const deploymentJobs = sortJobsByTitle([
13259
+ { id: 'adhoc-prompt', title: 'Ad-hoc (custom instructions)' },
13260
+ ...catalogJobs,
13261
+ ]);
13262
+ // Issue #1451: sort alphabetically by the displayed title so the New
13263
+ // Assignment job list is scannable. Ad-hoc remains available, but it is not
13264
+ // pinned above catalog jobs because it requires instructions before saving.
13265
+ for (const j of deploymentJobs) {
13054
13266
  const opt = document.createElement('option');
13055
13267
  opt.value = j.id; opt.textContent = j.title;
13056
13268
  jobSel.appendChild(opt);
13057
13269
  }
13058
- if (dep?.jobId) jobSel.value = dep.jobId;
13270
+ const defaultCatalogJob = sortJobsByTitle(catalogJobs)[0];
13271
+ const selectedJobId = dep?.jobId || defaultCatalogJob?.id || 'adhoc-prompt';
13272
+ if (selectedJobId) jobSel.value = selectedJobId;
13273
+ updateDepInstructionsLabel();
13274
+ jobSel.onchange = updateDepInstructionsLabel;
13059
13275
 
13060
13276
  populateAgentSelect(document.getElementById('dep-agent'), dep?.configuredAgentId || dep?.hostId || 'claude');
13061
13277
  document.getElementById('dep-label').value = dep?.label ?? '';
@@ -13103,6 +13319,9 @@ async function saveDeployment() {
13103
13319
  const instructions = document.getElementById('dep-instructions').value.trim();
13104
13320
  const isEdit = _editingDepId !== null;
13105
13321
  if (!label) { errEl.textContent = 'Name is required.'; errEl.hidden = false; return; }
13322
+ // Issue #1610 R9: adhoc-prompt has no fixed task — block save before any network
13323
+ // request so the user gets immediate feedback rather than a server 400.
13324
+ if (jobId === 'adhoc-prompt' && !instructions) { errEl.textContent = 'Instructions are required for ad-hoc assignments.'; errEl.hidden = false; return; }
13106
13325
 
13107
13326
  if (_depType === 'scheduled') {
13108
13327
  const isCustom = _activeSchPreset === 'custom';
@@ -6000,6 +6000,9 @@ img.eh-av { object-fit: cover; background: var(--surface); }
6000
6000
  }
6001
6001
  .dep-row:hover { background: var(--soft); }
6002
6002
  .dep-row-title { font-size: 13px; font-weight: 400; font-style: italic; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
6003
+ /* Issue #1610 R10: ad-hoc job label and instructions subtitle in assignment rows. */
6004
+ .dep-row-job-label { display: block; font-size: 11px; font-weight: 600; color: var(--accent); margin-top: 1px; }
6005
+ .dep-row-sub { display: block; font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
6003
6006
  .assign-row-meta { display: flex; gap: 8px; align-items: center; margin-top: 3px; font-size: 11px; color: var(--muted); }
6004
6007
  .assign-badge { font-size: 10px; font-weight: 700; letter-spacing: .02em; padding: 1px 7px; border-radius: 999px; }
6005
6008
  .assign-badge--scheduled { background: rgba(0,113,227,.10); color: var(--accent); }