@flowdesk/opencode-plugin 0.1.12 → 0.1.14

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 (98) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-task-output.d.ts +17 -0
  3. package/dist/agent-task-output.d.ts.map +1 -0
  4. package/dist/agent-task-output.js +119 -0
  5. package/dist/agent-task-output.js.map +1 -0
  6. package/dist/agent-task-runner.d.ts +24 -0
  7. package/dist/agent-task-runner.d.ts.map +1 -1
  8. package/dist/agent-task-runner.js +536 -61
  9. package/dist/agent-task-runner.js.map +1 -1
  10. package/dist/auto-continue-preview-tool.d.ts +36 -0
  11. package/dist/auto-continue-preview-tool.d.ts.map +1 -0
  12. package/dist/auto-continue-preview-tool.js +119 -0
  13. package/dist/auto-continue-preview-tool.js.map +1 -0
  14. package/dist/completion-ui-cache.d.ts +6 -0
  15. package/dist/completion-ui-cache.d.ts.map +1 -0
  16. package/dist/completion-ui-cache.js +260 -0
  17. package/dist/completion-ui-cache.js.map +1 -0
  18. package/dist/controlled-write-tool.d.ts +49 -0
  19. package/dist/controlled-write-tool.d.ts.map +1 -0
  20. package/dist/controlled-write-tool.js +296 -0
  21. package/dist/controlled-write-tool.js.map +1 -0
  22. package/dist/event-hook-observer.d.ts +14 -0
  23. package/dist/event-hook-observer.d.ts.map +1 -0
  24. package/dist/event-hook-observer.js +193 -0
  25. package/dist/event-hook-observer.js.map +1 -0
  26. package/dist/managed-dispatch-adapter.d.ts +1 -0
  27. package/dist/managed-dispatch-adapter.d.ts.map +1 -1
  28. package/dist/managed-dispatch-adapter.js +100 -29
  29. package/dist/managed-dispatch-adapter.js.map +1 -1
  30. package/dist/model-selection-engine.d.ts +47 -0
  31. package/dist/model-selection-engine.d.ts.map +1 -0
  32. package/dist/model-selection-engine.js +175 -0
  33. package/dist/model-selection-engine.js.map +1 -0
  34. package/dist/provider-usage-live-tool.d.ts +27 -0
  35. package/dist/provider-usage-live-tool.d.ts.map +1 -1
  36. package/dist/provider-usage-live-tool.js +443 -4
  37. package/dist/provider-usage-live-tool.js.map +1 -1
  38. package/dist/quick-reviewer-run.d.ts +3 -0
  39. package/dist/quick-reviewer-run.d.ts.map +1 -1
  40. package/dist/quick-reviewer-run.js +20 -8
  41. package/dist/quick-reviewer-run.js.map +1 -1
  42. package/dist/runtime-reviewer-execution-bridge.d.ts +21 -0
  43. package/dist/runtime-reviewer-execution-bridge.d.ts.map +1 -1
  44. package/dist/runtime-reviewer-execution-bridge.js +238 -0
  45. package/dist/runtime-reviewer-execution-bridge.js.map +1 -1
  46. package/dist/server.d.ts +60 -1
  47. package/dist/server.d.ts.map +1 -1
  48. package/dist/server.js +800 -41
  49. package/dist/server.js.map +1 -1
  50. package/dist/stall-recovery.d.ts +60 -0
  51. package/dist/stall-recovery.d.ts.map +1 -1
  52. package/dist/stall-recovery.js +763 -11
  53. package/dist/stall-recovery.js.map +1 -1
  54. package/dist/status-live-tool.d.ts +81 -0
  55. package/dist/status-live-tool.d.ts.map +1 -1
  56. package/dist/status-live-tool.js +620 -38
  57. package/dist/status-live-tool.js.map +1 -1
  58. package/dist/tui-subtask-activity.d.ts +69 -0
  59. package/dist/tui-subtask-activity.d.ts.map +1 -0
  60. package/dist/tui-subtask-activity.js +266 -0
  61. package/dist/tui-subtask-activity.js.map +1 -0
  62. package/dist/tui-usage-snapshot.d.ts +44 -0
  63. package/dist/tui-usage-snapshot.d.ts.map +1 -0
  64. package/dist/tui-usage-snapshot.js +397 -0
  65. package/dist/tui-usage-snapshot.js.map +1 -0
  66. package/dist/tui.d.ts +7 -0
  67. package/dist/tui.d.ts.map +1 -0
  68. package/dist/tui.js +134 -0
  69. package/dist/tui.js.map +1 -0
  70. package/dist/workflow-assign-tool.d.ts +23 -0
  71. package/dist/workflow-assign-tool.d.ts.map +1 -0
  72. package/dist/workflow-assign-tool.js +117 -0
  73. package/dist/workflow-assign-tool.js.map +1 -0
  74. package/dist/workflow-author-tool.d.ts +29 -0
  75. package/dist/workflow-author-tool.d.ts.map +1 -0
  76. package/dist/workflow-author-tool.js +227 -0
  77. package/dist/workflow-author-tool.js.map +1 -0
  78. package/dist/workflow-dispatch-plan-tool.d.ts +47 -0
  79. package/dist/workflow-dispatch-plan-tool.d.ts.map +1 -0
  80. package/dist/workflow-dispatch-plan-tool.js +251 -0
  81. package/dist/workflow-dispatch-plan-tool.js.map +1 -0
  82. package/dist/workflow-dispatch-tool.d.ts +56 -0
  83. package/dist/workflow-dispatch-tool.d.ts.map +1 -0
  84. package/dist/workflow-dispatch-tool.js +306 -0
  85. package/dist/workflow-dispatch-tool.js.map +1 -0
  86. package/dist/workflow-orchestrator.d.ts +31 -0
  87. package/dist/workflow-orchestrator.d.ts.map +1 -0
  88. package/dist/workflow-orchestrator.js +160 -0
  89. package/dist/workflow-orchestrator.js.map +1 -0
  90. package/dist/workflow-scheduler.d.ts +19 -0
  91. package/dist/workflow-scheduler.d.ts.map +1 -0
  92. package/dist/workflow-scheduler.js +45 -0
  93. package/dist/workflow-scheduler.js.map +1 -0
  94. package/dist/workflow-synthesis-tool.d.ts +31 -0
  95. package/dist/workflow-synthesis-tool.d.ts.map +1 -0
  96. package/dist/workflow-synthesis-tool.js +194 -0
  97. package/dist/workflow-synthesis-tool.js.map +1 -0
  98. package/package.json +10 -2
@@ -1,8 +1,21 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { applyFlowDeskSessionEvidenceWriteIntentsV1, prepareFlowDeskSessionEvidenceWriteIntentV1, } from "@flowdesk/core";
3
3
  import { launchFlowDeskInjectedSdkRuntimeLaneFromPlanV1, materializeFlowDeskRuntimeLaneLaunchLifecycleEvidenceV1, } from "./managed-dispatch-adapter.js";
4
+ import { observeFlowDeskAgentTaskOutputV1 } from "./agent-task-output.js";
5
+ import { refreshFlowDeskCompletionUiCachesV1 } from "./completion-ui-cache.js";
4
6
  import { recordFlowDeskLaneHeartbeatV1 } from "./lane-heartbeat-writer.js";
5
7
  const TASK_RESULT_MAX_TEXT = 32_768;
8
+ const AGENT_TASK_CONTEXT_MAX_PROMPT_TEXT = 32_768;
9
+ const INVALID_PARENT_SESSION_REF = "ses-invalid-parent-session-binding";
10
+ /** Schema version for async child session tracking evidence */
11
+ export const AGENT_TASK_CHILD_SESSION_SCHEMA_VERSION = "flowdesk.agent_task_child_session.v1";
12
+ export function sanitizeFlowDeskTaskResultTextV1(text) {
13
+ return {
14
+ text: text.length > TASK_RESULT_MAX_TEXT ? text.slice(0, TASK_RESULT_MAX_TEXT) : text,
15
+ changed: false,
16
+ truncated: text.length > TASK_RESULT_MAX_TEXT,
17
+ };
18
+ }
6
19
  function agentTaskLaunchPlan(input) {
7
20
  return {
8
21
  schema_version: "flowdesk.runtime_lane_launch_plan.v1",
@@ -31,38 +44,162 @@ function agentTaskLaunchPlan(input) {
31
44
  runtimeExecution: false,
32
45
  };
33
46
  }
34
- function extractAssistantTextFromResponse(client, childSessionId) {
35
- // We extract response text via messages API
47
+ function validateAgentTaskParentSessionId(parentSessionId) {
48
+ const value = parentSessionId.trim();
49
+ if (value.length === 0)
50
+ return { ok: false, redactedReason: "missing_parent_session_binding", parentSessionRef: INVALID_PARENT_SESSION_REF };
51
+ if (value.length > 128)
52
+ return { ok: false, redactedReason: "invalid_parent_session_binding", parentSessionRef: INVALID_PARENT_SESSION_REF };
53
+ // `ses-...` is FlowDesk's opaque session-ref wrapper, not the raw OpenCode
54
+ // session id expected by SDK `session.create({ parentID })`. Accepting it here
55
+ // causes evidence such as `ses-ses-flowdesk-coordinator` and can make the SDK
56
+ // wait on a non-existent synthetic parent session until launch timeout.
57
+ if (value.startsWith("ses-"))
58
+ return { ok: false, redactedReason: "invalid_parent_session_binding", parentSessionRef: INVALID_PARENT_SESSION_REF };
59
+ if (/\s/.test(value))
60
+ return { ok: false, redactedReason: "invalid_parent_session_binding", parentSessionRef: INVALID_PARENT_SESSION_REF };
61
+ if (!/^[A-Za-z0-9_.:-]+$/.test(value))
62
+ return { ok: false, redactedReason: "invalid_parent_session_binding", parentSessionRef: INVALID_PARENT_SESSION_REF };
63
+ return { ok: true, parentSessionRef: `ses-${value}` };
64
+ }
65
+ /** Bounded nudge text — versioned constant, never echoes user input */
66
+ const AGENT_TASK_NUDGE_TEXT = "Please provide your final answer now. If you have completed your analysis, output your complete response.";
67
+ /**
68
+ * Polls `session.messages` with a per-call 3-second cap so it works whether the SDK
69
+ * uses snapshot (returns immediately) or long-poll (blocks until output) semantics.
70
+ *
71
+ * Heartbeat: fires every `quietPeriodMs` of silence — only when inactive.
72
+ * Nudge: after `quietPeriodMs` of silence, sends a bounded prompt to the child
73
+ * session asking for the final answer. Max `maxNudges` nudges total.
74
+ * After exhausting nudges with no response, returns undefined.
75
+ */
76
+ async function extractAssistantTextFromResponse(client, childSessionId, opts) {
36
77
  const messages = client.session.messages;
37
78
  if (messages === undefined)
38
79
  return undefined;
39
- return (async () => {
80
+ const quietPeriodMs = opts?.quietPeriodMs ?? 30_000;
81
+ const maxNudges = opts?.maxNudges ?? 2;
82
+ const MESSAGES_TIMEOUT_MS = opts?.messagesTimeoutMs ?? 3_000; // per-call cap — handles both snapshot and long-poll
83
+ const method = messages;
84
+ /**
85
+ * Call session.messages with a ceiling timeout so we can check inactivity periodically.
86
+ * This handles both snapshot APIs (return immediately) and long-poll APIs
87
+ * (block until LLM produces output). With the timeout, a long-poll call that
88
+ * hasn't returned after MESSAGES_TIMEOUT_MS resolves as null so we can
89
+ * check the inactivity clock and possibly send a nudge.
90
+ */
91
+ const callMessages = () => {
92
+ const messagePromise = (async () => {
93
+ const current = await method.call(client.session, { sessionID: childSessionId });
94
+ if (isSdkErrorResponse(current))
95
+ return method.call(client.session, { path: { id: childSessionId } });
96
+ return current;
97
+ })();
98
+ // Only race against timeout when the API might block (MESSAGES_TIMEOUT_MS > 0)
99
+ if (MESSAGES_TIMEOUT_MS <= 0)
100
+ return messagePromise;
101
+ return Promise.race([
102
+ messagePromise,
103
+ new Promise(resolve => setTimeout(() => resolve(null), MESSAGES_TIMEOUT_MS)),
104
+ ]);
105
+ };
106
+ /** Send a nudge to the child session with a hard timeout to prevent blocking.
107
+ * Uses noReply: true so the child does not generate a spurious second assistant turn.
108
+ */
109
+ const sendNudge = async () => {
110
+ const promptFn = client.session.prompt ?? client.session.promptAsync;
111
+ if (promptFn === undefined)
112
+ return "skipped";
113
+ const NUDGE_TIMEOUT_MS = 5_000;
40
114
  try {
41
- const response = await messages.call(client.session, { path: { id: childSessionId } });
42
- const data = asResponseData(response);
43
- const items = Array.isArray(data) ? data : asRecord(data)?.items ?? [];
44
- for (const message of items) {
45
- const record = asRecord(message);
46
- if (record?.role !== "assistant")
47
- continue;
48
- const parts = Array.isArray(record.parts) ? record.parts : [];
49
- for (const part of parts) {
50
- const partRecord = asRecord(part);
51
- const text = typeof partRecord?.text === "string"
52
- ? partRecord.text
53
- : typeof partRecord?.content === "string"
54
- ? partRecord.content
55
- : undefined;
56
- if (typeof text === "string" && text.trim().length > 0)
57
- return text;
58
- }
59
- }
60
- return undefined;
115
+ await Promise.race([
116
+ promptFn.call(client.session, {
117
+ sessionID: childSessionId,
118
+ noReply: true,
119
+ ...(opts?.runtimeModel !== undefined ? { model: opts.runtimeModel } : {}),
120
+ ...(opts?.agentName !== undefined ? { agent: opts.agentName } : {}),
121
+ parts: [{ type: "text", text: AGENT_TASK_NUDGE_TEXT }],
122
+ }),
123
+ new Promise((_, reject) => setTimeout(() => reject(new Error("nudge timeout")), NUDGE_TIMEOUT_MS)),
124
+ ]);
125
+ return "sent";
61
126
  }
62
127
  catch {
63
- return undefined;
128
+ return "timeout";
64
129
  }
65
- })();
130
+ };
131
+ const observe = (response) => {
132
+ if (response === null)
133
+ return undefined; // timed-out poll cycle
134
+ return observeFlowDeskAgentTaskOutputV1(response);
135
+ };
136
+ const startMs = Date.now();
137
+ let lastActivityMs = startMs;
138
+ let lastSignature = "";
139
+ let lastHeartbeatMs = startMs;
140
+ let nudgeCount = 0;
141
+ let latestCandidate;
142
+ try {
143
+ while (true) {
144
+ const response = await callMessages();
145
+ const nowMs = Date.now();
146
+ // Build signature (null response = timeout, no change)
147
+ const sig = response === null ? lastSignature : (() => {
148
+ const data = asResponseData(response);
149
+ const record = asRecord(data);
150
+ const items = Array.isArray(data) ? data
151
+ : Array.isArray(record?.items) ? record.items
152
+ : Array.isArray(record?.messages) ? record.messages : [];
153
+ const observed = observe(response);
154
+ return `${items.length}:${observed?.latestText?.length ?? 0}:${observed?.terminalObserved === true ? "terminal" : "open"}`;
155
+ })();
156
+ if (sig !== lastSignature) {
157
+ // New activity — reset all inactivity clocks
158
+ lastSignature = sig;
159
+ lastActivityMs = nowMs;
160
+ lastHeartbeatMs = nowMs;
161
+ }
162
+ const observed = observe(response);
163
+ if (observed?.latestText !== undefined && observed.latestText.trim().length > 0)
164
+ latestCandidate = observed;
165
+ if (observed?.terminalObserved === true && observed.latestText !== undefined && observed.latestText.trim().length > 0) {
166
+ return { text: observed.latestText, completionStatus: "final", outputKind: observed.outputKind, usableForSynthesis: observed.usableForSynthesis };
167
+ }
168
+ const silenceMs = nowMs - lastActivityMs;
169
+ if (silenceMs >= quietPeriodMs) {
170
+ // Emit heartbeat on first quiet-period expiry of each silence window
171
+ if (nowMs - lastHeartbeatMs >= quietPeriodMs) {
172
+ lastHeartbeatMs = nowMs;
173
+ opts?.heartbeatFn?.(nowMs - startMs);
174
+ }
175
+ // Send nudge after quiet period
176
+ if (nudgeCount < maxNudges) {
177
+ nudgeCount++;
178
+ await sendNudge();
179
+ // Reset activity clock after nudge — give a fresh quiet window
180
+ lastActivityMs = Date.now();
181
+ lastHeartbeatMs = lastActivityMs;
182
+ }
183
+ else {
184
+ // Exhausted all nudges. Preserve usable candidate text as partial output.
185
+ if (latestCandidate?.latestText !== undefined && latestCandidate.latestText.trim().length > 0) {
186
+ return { text: latestCandidate.latestText, completionStatus: "partial", outputKind: latestCandidate.outputKind, usableForSynthesis: latestCandidate.usableForSynthesis };
187
+ }
188
+ return undefined;
189
+ }
190
+ }
191
+ else {
192
+ // No activity and not yet at quiet period — yield to event loop before next poll.
193
+ // Sleep for up to 1s or quietPeriodMs/10, whichever is smaller, to avoid tight loops
194
+ // while still being responsive when messages arrive quickly (snapshot mode).
195
+ const yieldMs = Math.max(10, Math.min(1_000, Math.floor(quietPeriodMs / 10)));
196
+ await new Promise(resolve => setTimeout(resolve, yieldMs));
197
+ }
198
+ }
199
+ }
200
+ catch {
201
+ return undefined;
202
+ }
66
203
  }
67
204
  function asRecord(value) {
68
205
  return typeof value === "object" && value !== null && !Array.isArray(value)
@@ -73,12 +210,126 @@ function asResponseData(value) {
73
210
  const record = asRecord(value);
74
211
  return record !== undefined && "data" in record ? record.data : value;
75
212
  }
213
+ function isSdkErrorResponse(value) {
214
+ const record = asRecord(value);
215
+ const data = asRecord(asResponseData(value));
216
+ return record?.error !== undefined || data?.error !== undefined;
217
+ }
76
218
  function sha256Hex(text) {
77
219
  return createHash("sha256").update(text, "utf8").digest("hex");
78
220
  }
221
+ function writeSessionEvidence(input) {
222
+ const prepared = prepareFlowDeskSessionEvidenceWriteIntentV1({
223
+ workflowId: input.workflowId,
224
+ evidenceId: input.evidenceId,
225
+ record: input.record,
226
+ });
227
+ if (prepared.ok && prepared.writeIntent !== undefined) {
228
+ const applied = applyFlowDeskSessionEvidenceWriteIntentsV1(input.rootDir, [prepared.writeIntent]);
229
+ return applied.ok && applied.writtenPaths.length > 0;
230
+ }
231
+ return false;
232
+ }
233
+ function progressLabel(value) {
234
+ const compact = value.replace(/\s+/g, " ").trim();
235
+ return compact.length > 120 ? `${compact.slice(0, 119)}…` : compact;
236
+ }
237
+ function writeAgentTaskProgress(input) {
238
+ const observedAt = input.observedAt ?? new Date().toISOString();
239
+ const record = {
240
+ schema_version: "flowdesk.agent_task_progress.v1",
241
+ workflow_id: input.workflowId,
242
+ lane_id: input.laneId,
243
+ task_id: input.taskId,
244
+ agent_ref: input.agentRef,
245
+ provider_qualified_model_id: input.providerQualifiedModelId,
246
+ progress_seq: input.progressSeq,
247
+ observed_at: observedAt,
248
+ phase: input.phase,
249
+ progress_label: progressLabel(input.progressLabel),
250
+ progress_ref: `progress-${input.laneId}-${input.progressSeq}`,
251
+ redaction_version: "v1",
252
+ dispatch_authority_enabled: false,
253
+ };
254
+ writeSessionEvidence({
255
+ rootDir: input.rootDir,
256
+ workflowId: input.workflowId,
257
+ evidenceId: `agent-task-progress-${input.laneId}-${input.progressSeq}`,
258
+ record: record,
259
+ });
260
+ }
261
+ function writeAgentTaskTerminalLifecycle(input) {
262
+ const childSessionRef = input.childSessionRef === input.parentSessionRef ? undefined : input.childSessionRef;
263
+ const record = {
264
+ schema_version: "flowdesk.lane_lifecycle_record.v1",
265
+ lane_id: input.laneId,
266
+ workflow_id: input.workflowId,
267
+ attempt_id: input.attemptId,
268
+ parent_session_ref: input.parentSessionRef,
269
+ ...(childSessionRef === undefined ? {} : { child_session_ref: childSessionRef }),
270
+ ...(input.messageRef === undefined ? {} : { message_ref: input.messageRef }),
271
+ agent_ref: input.agentRef,
272
+ provider_qualified_model_id: input.providerQualifiedModelId,
273
+ state: input.state,
274
+ ...(input.outputRef === undefined ? {} : { output_ref: input.outputRef }),
275
+ timeout_ms: input.timeoutMs ?? 0,
276
+ orphan_max_age_ms: 0,
277
+ retry_count: 0,
278
+ created_at: input.createdAt,
279
+ updated_at: input.updatedAt,
280
+ dispatch_authority_enabled: false,
281
+ providerCall: false,
282
+ actualLaneLaunch: false,
283
+ runtimeExecution: false,
284
+ };
285
+ writeSessionEvidence({
286
+ rootDir: input.rootDir,
287
+ workflowId: input.workflowId,
288
+ evidenceId: input.evidenceId,
289
+ record: record,
290
+ });
291
+ }
79
292
  export async function executeFlowDeskAgentTaskV1(input) {
80
293
  const observedAt = new Date().toISOString();
81
294
  const token = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
295
+ const parentBinding = validateAgentTaskParentSessionId(input.parentSessionId);
296
+ const parentSessionRef = parentBinding.parentSessionRef;
297
+ const attemptId = `attempt-task-${token}`;
298
+ if (!parentBinding.ok) {
299
+ const taskFailedEvidenceId = `task-failed-${input.taskId}-${token}-invalid-parent`;
300
+ const redactedReason = parentBinding.redactedReason;
301
+ writeSessionEvidence({
302
+ rootDir: input.rootDir,
303
+ workflowId: input.workflowId,
304
+ evidenceId: taskFailedEvidenceId,
305
+ record: {
306
+ schema_version: "flowdesk.task_failed.v1",
307
+ workflow_id: input.workflowId,
308
+ lane_id: input.laneId,
309
+ task_id: input.taskId,
310
+ agent_ref: input.agentRef,
311
+ provider_qualified_model_id: input.providerQualifiedModelId,
312
+ failure_category: "sdk_create_failed",
313
+ redacted_reason: redactedReason,
314
+ created_at: observedAt,
315
+ dispatch_authority_enabled: false,
316
+ },
317
+ });
318
+ writeAgentTaskTerminalLifecycle({
319
+ rootDir: input.rootDir,
320
+ workflowId: input.workflowId,
321
+ laneId: input.laneId,
322
+ attemptId,
323
+ parentSessionRef,
324
+ agentRef: input.agentRef,
325
+ providerQualifiedModelId: input.providerQualifiedModelId,
326
+ state: "invocation_failed",
327
+ evidenceId: `lifecycle-task-terminal-${input.laneId}-${token}-invalid-parent`,
328
+ createdAt: observedAt,
329
+ updatedAt: observedAt,
330
+ });
331
+ return { status: "task_failed", failureCategory: "sdk_create_failed", redactedReason, laneId: input.laneId };
332
+ }
82
333
  const launchPlan = agentTaskLaunchPlan({
83
334
  workflowId: input.workflowId,
84
335
  laneId: input.laneId,
@@ -88,17 +339,97 @@ export async function executeFlowDeskAgentTaskV1(input) {
88
339
  token,
89
340
  });
90
341
  const runningLifecycleEvidenceId = `lifecycle-task-running-${input.laneId}-${token}`;
91
- // Launch the lane
92
- const launchResult = await launchFlowDeskInjectedSdkRuntimeLaneFromPlanV1({
93
- client: input.client,
94
- launchPlan,
95
- request: {
96
- allowActualLaneLaunch: true,
97
- parentSessionId: input.parentSessionId,
98
- promptText: input.promptText,
99
- dispatchMethod: "prompt",
100
- },
342
+ const promptTextTruncated = input.promptText.length > AGENT_TASK_CONTEXT_MAX_PROMPT_TEXT;
343
+ const agentTaskContextRecord = {
344
+ schema_version: "flowdesk.agent_task_context.v1",
345
+ workflow_id: input.workflowId,
346
+ lane_id: input.laneId,
347
+ task_id: input.taskId,
348
+ agent_ref: input.agentRef,
349
+ provider_qualified_model_id: input.providerQualifiedModelId,
350
+ parent_session_ref: parentSessionRef,
351
+ prompt_text: promptTextTruncated
352
+ ? input.promptText.slice(0, AGENT_TASK_CONTEXT_MAX_PROMPT_TEXT)
353
+ : input.promptText,
354
+ prompt_text_truncated: promptTextTruncated,
355
+ prompt_text_sha256: sha256Hex(input.promptText),
356
+ redaction_version: "v1",
357
+ created_at: observedAt,
358
+ dispatch_authority_enabled: false,
359
+ };
360
+ writeSessionEvidence({
361
+ rootDir: input.rootDir,
362
+ workflowId: input.workflowId,
363
+ evidenceId: `agent-task-context-${input.taskId}-${token}`,
364
+ record: agentTaskContextRecord,
101
365
  });
366
+ writeAgentTaskProgress({
367
+ rootDir: input.rootDir,
368
+ workflowId: input.workflowId,
369
+ laneId: input.laneId,
370
+ taskId: input.taskId,
371
+ agentRef: input.agentRef,
372
+ providerQualifiedModelId: input.providerQualifiedModelId,
373
+ phase: "started",
374
+ progressSeq: 1,
375
+ progressLabel: "agent task lane launch started",
376
+ observedAt,
377
+ });
378
+ // Launch the lane — wrap in absolute timeout so session.prompt blocking doesn't hang forever.
379
+ // The launch phase timeout is longer (5 min) since promptAsync may queue work before responding.
380
+ // 1 min default — if session.prompt blocks for more than 1 min with no activity, give up
381
+ const LAUNCH_TIMEOUT_MS = input._launchTimeoutMs ?? 60_000;
382
+ const launchTimeoutHandle = setTimeout(() => { }, LAUNCH_TIMEOUT_MS);
383
+ const dispatchMethod = input.client.session.promptAsync !== undefined ? "promptAsync" : "prompt";
384
+ const launchResult = await Promise.race([
385
+ launchFlowDeskInjectedSdkRuntimeLaneFromPlanV1({
386
+ client: input.client,
387
+ launchPlan,
388
+ request: {
389
+ allowActualLaneLaunch: true,
390
+ parentSessionId: input.parentSessionId,
391
+ promptText: input.promptText,
392
+ dispatchMethod,
393
+ },
394
+ }),
395
+ new Promise(resolve => setTimeout(() => resolve({ status: "launch_timeout" }), LAUNCH_TIMEOUT_MS)),
396
+ ]);
397
+ clearTimeout(launchTimeoutHandle);
398
+ if ("status" in launchResult && launchResult.status === "launch_timeout") {
399
+ // session.prompt blocked for too long — treat as invocation failure
400
+ const failedEvidenceId = `task-failed-${input.taskId}-${token}-launch-timeout`;
401
+ writeSessionEvidence({
402
+ rootDir: input.rootDir,
403
+ workflowId: input.workflowId,
404
+ evidenceId: failedEvidenceId,
405
+ record: {
406
+ schema_version: "flowdesk.task_failed.v1",
407
+ workflow_id: input.workflowId,
408
+ lane_id: input.laneId,
409
+ task_id: input.taskId,
410
+ agent_ref: input.agentRef,
411
+ provider_qualified_model_id: input.providerQualifiedModelId,
412
+ failure_category: "sdk_create_failed",
413
+ redacted_reason: "lane launch timed out: session.prompt did not respond",
414
+ created_at: observedAt,
415
+ dispatch_authority_enabled: false,
416
+ },
417
+ });
418
+ writeAgentTaskTerminalLifecycle({
419
+ rootDir: input.rootDir,
420
+ workflowId: input.workflowId,
421
+ laneId: input.laneId,
422
+ attemptId,
423
+ parentSessionRef,
424
+ agentRef: input.agentRef,
425
+ providerQualifiedModelId: input.providerQualifiedModelId,
426
+ state: "invocation_failed",
427
+ evidenceId: `lifecycle-task-terminal-${input.laneId}-${token}-launch-timeout`,
428
+ createdAt: observedAt,
429
+ updatedAt: new Date().toISOString(),
430
+ });
431
+ return { status: "task_failed", failureCategory: "sdk_create_failed", redactedReason: "launch timeout: session.prompt did not respond within the allowed window", laneId: input.laneId };
432
+ }
102
433
  // Write running lifecycle evidence
103
434
  materializeFlowDeskRuntimeLaneLaunchLifecycleEvidenceV1({
104
435
  rootDir: input.rootDir,
@@ -112,9 +443,9 @@ export async function executeFlowDeskAgentTaskV1(input) {
112
443
  recordFlowDeskLaneHeartbeatV1({
113
444
  rootDir: input.rootDir,
114
445
  workflowId: input.workflowId,
115
- attemptId: launchPlan.attempt_id ?? `attempt-task-${token}`,
446
+ attemptId,
116
447
  laneId: input.laneId,
117
- parentSessionRef: `ses-${input.parentSessionId}`,
448
+ parentSessionRef,
118
449
  agentRef: input.agentRef,
119
450
  providerQualifiedModelId: input.providerQualifiedModelId,
120
451
  state: "running",
@@ -137,14 +468,26 @@ export async function executeFlowDeskAgentTaskV1(input) {
137
468
  created_at: observedAt,
138
469
  dispatch_authority_enabled: false,
139
470
  };
140
- const taskFailedPrepared = prepareFlowDeskSessionEvidenceWriteIntentV1({
471
+ writeSessionEvidence({
472
+ rootDir: input.rootDir,
141
473
  workflowId: input.workflowId,
142
474
  evidenceId: taskFailedEvidenceId,
143
475
  record: taskFailedRecord,
144
476
  });
145
- if (taskFailedPrepared.ok && taskFailedPrepared.writeIntent !== undefined) {
146
- applyFlowDeskSessionEvidenceWriteIntentsV1(input.rootDir, [taskFailedPrepared.writeIntent]);
147
- }
477
+ writeAgentTaskTerminalLifecycle({
478
+ rootDir: input.rootDir,
479
+ workflowId: input.workflowId,
480
+ laneId: input.laneId,
481
+ attemptId,
482
+ parentSessionRef,
483
+ agentRef: input.agentRef,
484
+ providerQualifiedModelId: input.providerQualifiedModelId,
485
+ state: "invocation_failed",
486
+ evidenceId: `lifecycle-task-terminal-${input.laneId}-${token}`,
487
+ createdAt: observedAt,
488
+ updatedAt: new Date().toISOString(),
489
+ timeoutMs: input.timeoutMs,
490
+ });
148
491
  return {
149
492
  status: "task_failed",
150
493
  failureCategory,
@@ -153,30 +496,93 @@ export async function executeFlowDeskAgentTaskV1(input) {
153
496
  };
154
497
  }
155
498
  // Lane launched successfully - record heartbeat
156
- const attemptId = launchPlan.attempt_id ?? `attempt-task-${token}`;
157
499
  recordFlowDeskLaneHeartbeatV1({
158
500
  rootDir: input.rootDir,
159
501
  workflowId: input.workflowId,
160
502
  attemptId,
161
503
  laneId: input.laneId,
162
- parentSessionRef: `ses-${input.parentSessionId}`,
504
+ parentSessionRef,
163
505
  agentRef: input.agentRef,
164
506
  providerQualifiedModelId: input.providerQualifiedModelId,
165
507
  state: "running",
166
508
  observedAt,
167
509
  progressSummaryLabel: `agent task lane launch heartbeat`,
168
510
  });
169
- // Extract child session ID and get response text
511
+ // Extract child session ID
170
512
  const childSessionId = launchResult.childSessionRef?.startsWith("ses-")
171
513
  ? launchResult.childSessionRef.slice("ses-".length)
172
514
  : undefined;
173
- let resultText;
515
+ // ── Async mode: return immediately, watchdog handles polling/nudging/abort ──
516
+ if (input.asyncMode === true) {
517
+ const resolvedChildId = childSessionId ?? "";
518
+ // Write child session evidence so watchdog can find it
519
+ writeSessionEvidence({
520
+ rootDir: input.rootDir,
521
+ workflowId: input.workflowId,
522
+ evidenceId: `agent-task-child-session-${input.laneId}-${token}`,
523
+ record: {
524
+ schema_version: AGENT_TASK_CHILD_SESSION_SCHEMA_VERSION,
525
+ workflow_id: input.workflowId,
526
+ lane_id: input.laneId,
527
+ task_id: input.taskId,
528
+ child_session_id: resolvedChildId,
529
+ parent_session_ref: parentSessionRef,
530
+ provider_qualified_model_id: input.providerQualifiedModelId,
531
+ agent_ref: input.agentRef,
532
+ nudge_count: 0,
533
+ last_nudge_at: null,
534
+ created_at: observedAt,
535
+ dispatch_authority_enabled: false,
536
+ },
537
+ });
538
+ writeAgentTaskProgress({
539
+ rootDir: input.rootDir,
540
+ workflowId: input.workflowId,
541
+ laneId: input.laneId,
542
+ taskId: input.taskId,
543
+ agentRef: input.agentRef,
544
+ providerQualifiedModelId: input.providerQualifiedModelId,
545
+ phase: "waiting",
546
+ progressSeq: 2,
547
+ progressLabel: "agent task waiting for async child result",
548
+ });
549
+ return { status: "task_launched", laneId: input.laneId, childSessionId: resolvedChildId };
550
+ }
551
+ let resultObservation;
174
552
  if (childSessionId !== undefined) {
175
- resultText = await extractAssistantTextFromResponse(input.client, childSessionId);
553
+ const runtimeModel = launchResult.status === "lane_launch_started" && typeof launchResult.model === "string"
554
+ ? launchResult.model : undefined;
555
+ const agentName = launchResult.status === "lane_launch_started" && typeof launchResult.agent === "string"
556
+ ? launchResult.agent : undefined;
557
+ resultObservation = await extractAssistantTextFromResponse(input.client, childSessionId, {
558
+ quietPeriodMs: input._nudgeQuietPeriodMs ?? 20_000, // default 20s per policy
559
+ maxNudges: 2,
560
+ runtimeModel,
561
+ agentName,
562
+ messagesTimeoutMs: input._messagesTimeoutMs,
563
+ heartbeatFn: (elapsedMs) => {
564
+ recordFlowDeskLaneHeartbeatV1({
565
+ rootDir: input.rootDir,
566
+ workflowId: input.workflowId,
567
+ attemptId,
568
+ laneId: input.laneId,
569
+ parentSessionRef,
570
+ agentRef: input.agentRef,
571
+ providerQualifiedModelId: input.providerQualifiedModelId,
572
+ state: "running",
573
+ observedAt: new Date().toISOString(),
574
+ progressSummaryLabel: `agent task waiting for response elapsed=${Math.floor(elapsedMs / 1000)}s`,
575
+ });
576
+ },
577
+ });
176
578
  }
579
+ const resultText = resultObservation?.text;
177
580
  if (resultText === undefined) {
178
581
  // No response text - write task_failed
179
582
  const taskFailedEvidenceId = `task-failed-${input.taskId}-${token}`;
583
+ const failureCategory = "no_response";
584
+ const evidenceFailureCategory = "no_response";
585
+ const redactedReason = "lane launched but no assistant response text found";
180
586
  const taskFailedRecord = {
181
587
  schema_version: "flowdesk.task_failed.v1",
182
588
  workflow_id: input.workflowId,
@@ -184,30 +590,54 @@ export async function executeFlowDeskAgentTaskV1(input) {
184
590
  task_id: input.taskId,
185
591
  agent_ref: input.agentRef,
186
592
  provider_qualified_model_id: input.providerQualifiedModelId,
187
- failure_category: "no_response",
188
- redacted_reason: "lane launched but no assistant response text found",
593
+ failure_category: evidenceFailureCategory,
594
+ redacted_reason: redactedReason,
189
595
  created_at: observedAt,
190
596
  dispatch_authority_enabled: false,
191
597
  };
192
- const taskFailedPrepared = prepareFlowDeskSessionEvidenceWriteIntentV1({
598
+ writeSessionEvidence({
599
+ rootDir: input.rootDir,
193
600
  workflowId: input.workflowId,
194
601
  evidenceId: taskFailedEvidenceId,
195
602
  record: taskFailedRecord,
196
603
  });
197
- if (taskFailedPrepared.ok && taskFailedPrepared.writeIntent !== undefined) {
198
- applyFlowDeskSessionEvidenceWriteIntentsV1(input.rootDir, [taskFailedPrepared.writeIntent]);
199
- }
604
+ writeAgentTaskProgress({
605
+ rootDir: input.rootDir,
606
+ workflowId: input.workflowId,
607
+ laneId: input.laneId,
608
+ taskId: input.taskId,
609
+ agentRef: input.agentRef,
610
+ providerQualifiedModelId: input.providerQualifiedModelId,
611
+ phase: "failed",
612
+ progressSeq: 3,
613
+ progressLabel: failureCategory === "no_response" ? "agent task finished without response" : "agent task output contract not satisfied",
614
+ });
615
+ writeAgentTaskTerminalLifecycle({
616
+ rootDir: input.rootDir,
617
+ workflowId: input.workflowId,
618
+ laneId: input.laneId,
619
+ attemptId,
620
+ parentSessionRef,
621
+ childSessionRef: launchResult.childSessionRef,
622
+ messageRef: launchResult.messageRef?.startsWith("msg-") ? launchResult.messageRef : undefined,
623
+ agentRef: input.agentRef,
624
+ providerQualifiedModelId: input.providerQualifiedModelId,
625
+ state: "no_output",
626
+ evidenceId: `lifecycle-task-terminal-${input.laneId}-${token}`,
627
+ createdAt: observedAt,
628
+ updatedAt: new Date().toISOString(),
629
+ timeoutMs: input.timeoutMs,
630
+ });
200
631
  return {
201
632
  status: "task_failed",
202
- failureCategory: "no_response",
203
- redactedReason: "lane launched but no assistant response text found",
633
+ failureCategory,
634
+ redactedReason,
204
635
  laneId: input.laneId,
205
636
  };
206
637
  }
207
- // Truncate if needed
208
638
  const fullResultText = resultText;
209
- const truncated = fullResultText.length > TASK_RESULT_MAX_TEXT;
210
- const storedResultText = truncated ? fullResultText.slice(0, TASK_RESULT_MAX_TEXT) : fullResultText;
639
+ const sanitizedResult = sanitizeFlowDeskTaskResultTextV1(fullResultText);
640
+ const storedResultText = sanitizedResult.text;
211
641
  const promptSha256 = sha256Hex(input.promptText);
212
642
  const resultSha256 = sha256Hex(fullResultText);
213
643
  // Write task_result evidence
@@ -221,19 +651,64 @@ export async function executeFlowDeskAgentTaskV1(input) {
221
651
  provider_qualified_model_id: input.providerQualifiedModelId,
222
652
  task_prompt_sha256: promptSha256,
223
653
  result_text: storedResultText,
224
- result_text_truncated: truncated,
654
+ result_text_truncated: sanitizedResult.truncated,
225
655
  result_text_sha256: resultSha256,
656
+ completion_status: resultObservation?.completionStatus ?? "final",
657
+ output_kind: resultObservation?.outputKind ?? "final_answer",
658
+ usable_for_synthesis: resultObservation?.usableForSynthesis ?? true,
659
+ missing_contract: input.outputContract === "final_assistant_text" &&
660
+ (resultObservation?.completionStatus !== "final" ||
661
+ ["empty", "process_notes", "tool_trace_only"].includes(String(resultObservation?.outputKind ?? ""))),
226
662
  created_at: observedAt,
227
663
  dispatch_authority_enabled: false,
228
664
  };
229
- const taskResultPrepared = prepareFlowDeskSessionEvidenceWriteIntentV1({
665
+ const taskResultWritten = writeSessionEvidence({
666
+ rootDir: input.rootDir,
230
667
  workflowId: input.workflowId,
231
668
  evidenceId: taskResultEvidenceId,
232
669
  record: taskResultRecord,
233
670
  });
234
- if (taskResultPrepared.ok && taskResultPrepared.writeIntent !== undefined) {
235
- applyFlowDeskSessionEvidenceWriteIntentsV1(input.rootDir, [taskResultPrepared.writeIntent]);
671
+ if (!taskResultWritten) {
672
+ return {
673
+ status: "task_failed",
674
+ failureCategory: "unknown",
675
+ redactedReason: "task_result evidence persistence failed",
676
+ laneId: input.laneId,
677
+ };
236
678
  }
679
+ writeAgentTaskProgress({
680
+ rootDir: input.rootDir,
681
+ workflowId: input.workflowId,
682
+ laneId: input.laneId,
683
+ taskId: input.taskId,
684
+ agentRef: input.agentRef,
685
+ providerQualifiedModelId: input.providerQualifiedModelId,
686
+ phase: "finalizing",
687
+ progressSeq: 3,
688
+ progressLabel: "agent task result captured",
689
+ });
690
+ writeAgentTaskTerminalLifecycle({
691
+ rootDir: input.rootDir,
692
+ workflowId: input.workflowId,
693
+ laneId: input.laneId,
694
+ attemptId,
695
+ parentSessionRef,
696
+ childSessionRef: launchResult.childSessionRef,
697
+ messageRef: launchResult.messageRef?.startsWith("msg-") ? launchResult.messageRef : undefined,
698
+ agentRef: input.agentRef,
699
+ providerQualifiedModelId: input.providerQualifiedModelId,
700
+ state: "incomplete",
701
+ outputRef: `output-${taskResultEvidenceId}`,
702
+ evidenceId: `lifecycle-task-terminal-${input.laneId}-${token}`,
703
+ createdAt: observedAt,
704
+ updatedAt: new Date().toISOString(),
705
+ timeoutMs: input.timeoutMs,
706
+ });
707
+ refreshFlowDeskCompletionUiCachesV1({
708
+ rootDir: input.rootDir,
709
+ workflowId: input.workflowId,
710
+ observedAt,
711
+ });
237
712
  return {
238
713
  status: "task_completed",
239
714
  resultText: fullResultText,