mixdog 1.3.0 → 1.3.1

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 (103) hide show
  1. package/NOTICE.md +52 -9
  2. package/package.json +1 -1
  3. package/src/defaults/shipped-history.json +4 -1
  4. package/src/defaults/skills/setup/references/actions.md +4 -1
  5. package/src/defaults/skills/setup-guide/SKILL.md +10 -7
  6. package/src/output-styles/common.md +13 -8
  7. package/src/rules/agent-mode/AGENT_MODE.md +1 -1
  8. package/src/rules/agent-mode/MIXY.md +11 -4
  9. package/src/runtime/agent/orchestrator/context/role-instructions.mjs +72 -14
  10. package/src/runtime/agent/orchestrator/mcp/agent-connectors.mjs +2 -1
  11. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +7 -2
  12. package/src/runtime/agent/orchestrator/providers/auto-effort.mjs +7 -8
  13. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +3 -1
  14. package/src/runtime/agent/orchestrator/providers/lib/tool-call-wire-ids.mjs +65 -0
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +8 -2
  16. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +9 -2
  17. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +3 -1
  18. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +14 -2
  19. package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +17 -0
  20. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +4 -2
  21. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +14 -4
  22. package/src/runtime/agent/orchestrator/runtime-core/workflow/agents.mjs +37 -7
  23. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +5 -3
  24. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +4 -0
  25. package/src/runtime/agent/orchestrator/session/agent-room.mjs +17 -8
  26. package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +32 -5
  27. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +9 -0
  28. package/src/runtime/agent/orchestrator/session/loop/route-fallback.mjs +108 -6
  29. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +8 -14
  30. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +46 -4
  31. package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +18 -8
  32. package/src/runtime/agent/orchestrator/session/manager/provider-replay.mjs +50 -0
  33. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -20
  34. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +3 -0
  35. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +7 -1
  36. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/session/mixy-name.mjs +5 -2
  38. package/src/runtime/agent/orchestrator/session/routine-result.d.mts +17 -1
  39. package/src/runtime/agent/orchestrator/session/routine-result.mjs +61 -9
  40. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +14 -9
  41. package/src/runtime/agent/orchestrator/session/transcript-restore/user-text.mjs +92 -15
  42. package/src/runtime/attachments/store.mjs +6 -0
  43. package/src/runtime/effort-judge/judge-client.mjs +1 -1
  44. package/src/runtime/effort-judge/model-manifest.json +7 -7
  45. package/src/runtime/shared/agent-kind.mjs +152 -0
  46. package/src/runtime/shared/agent-route-config.d.mts +1 -0
  47. package/src/runtime/shared/agent-route-config.mjs +22 -8
  48. package/src/runtime/shared/agent-store.mjs +132 -0
  49. package/src/runtime/shared/bot-split-migration.mjs +457 -0
  50. package/src/runtime/shared/config-patch.mjs +20 -1
  51. package/src/runtime/shared/connector-catalog.d.mts +1 -2
  52. package/src/runtime/shared/connector-catalog.mjs +2 -4
  53. package/src/runtime/shared/connector-triggers.mjs +2 -0
  54. package/src/runtime/shared/err-text.mjs +3 -1
  55. package/src/runtime/shared/error-presentation.mjs +91 -6
  56. package/src/runtime/shared/handoff-artifacts.d.mts +20 -0
  57. package/src/runtime/shared/handoff-artifacts.mjs +61 -0
  58. package/src/runtime/shared/llm/provider-model-identities.mjs +6 -1
  59. package/src/runtime/shared/llm/usage-accounting.mjs +7 -1
  60. package/src/runtime/shared/model-route-fallback.mjs +79 -7
  61. package/src/runtime/shared/plain-preview.mjs +22 -17
  62. package/src/runtime/shared/sensitive-path-rules.d.mts +15 -0
  63. package/src/runtime/shared/sensitive-path-rules.mjs +256 -0
  64. package/src/runtime/shared/usage-readout-totals.mjs +15 -13
  65. package/src/runtime/shared/user-data-guard.mjs +5 -2
  66. package/src/session-runtime/agent-home-delivery.mjs +423 -56
  67. package/src/session-runtime/agent-room-delivery.mjs +45 -4
  68. package/src/session-runtime/agent-task-sessions.mjs +299 -39
  69. package/src/session-runtime/boot/apis.mjs +13 -0
  70. package/src/session-runtime/boot/providers.mjs +14 -4
  71. package/src/session-runtime/config-lifecycle.mjs +43 -2
  72. package/src/session-runtime/handoff-attachments.mjs +593 -0
  73. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +27 -3
  74. package/src/session-runtime/model-route/route-persist.mjs +2 -2
  75. package/src/session-runtime/model-route/route-tuning.mjs +7 -1
  76. package/src/session-runtime/model-route/set-route.mjs +21 -1
  77. package/src/session-runtime/native-web-search.mjs +2 -2
  78. package/src/session-runtime/new-session-config.mjs +6 -1
  79. package/src/session-runtime/route-resolution.mjs +5 -11
  80. package/src/session-runtime/services/agent-tool/helpers.mjs +24 -5
  81. package/src/session-runtime/services/agent-tool/send-flow.mjs +11 -3
  82. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +4 -1
  83. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +5 -8
  84. package/src/session-runtime/services/agent-tool/tool-def.mjs +7 -0
  85. package/src/session-runtime/setup-tool/executor.mjs +12 -1
  86. package/src/session-runtime/setup-tool/extended-actions.mjs +19 -7
  87. package/src/session-runtime/setup-tool/settings-contract.mjs +6 -0
  88. package/src/session-runtime/tool-policy-refresh.mjs +13 -2
  89. package/src/session-runtime/turn/turn-run/turn-prepare.mjs +11 -0
  90. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +123 -32
  91. package/src/session-runtime/workflow-agents-api/agent-route.mjs +67 -7
  92. package/src/session-runtime/workflow-agents-api/onboarding.mjs +11 -4
  93. package/src/standalone/daemon-session-control.mjs +13 -5
  94. package/src/standalone/daemon.mjs +16 -4
  95. package/src/standalone/session-service/agent-tree/agent-turns.mjs +46 -3
  96. package/src/standalone/session-service/agent-tree.mjs +4 -3
  97. package/src/standalone/session-service/session-calls/session-turns.mjs +19 -0
  98. package/src/tui/dist/index.mjs +70 -9
  99. package/src/tui/session/notice-surface.mjs +3 -4
  100. package/src/tui/session/session-api/routes.mjs +14 -3
  101. package/src/tui/session/transcript-intake.mjs +2 -0
  102. package/src/tui/session/turn-live-call.mjs +11 -3
  103. package/src/tui/session/turn.mjs +13 -3
@@ -41,7 +41,7 @@ export function usageDeltaEvent({
41
41
  usage,
42
42
  sendTools,
43
43
  toolCalls = 0,
44
- generationMs = 0,
44
+ requestMs = 0,
45
45
  }) {
46
46
  return {
47
47
  sessionId,
@@ -57,9 +57,10 @@ export function usageDeltaEvent({
57
57
  deltaToolCalls: toolCalls,
58
58
  ...reasoningUsage(usage),
59
59
  deltaPrompt: usage.promptTokens || 0,
60
- // First visible token → end of this response; 0 when the call was not
61
- // timed (nothing visible streamed). Only timed output enters speed figures.
62
- generationMs,
60
+ // Request start → end of this response (first-token wait included, as the
61
+ // ledger's duration_ms); 0 when the call was not timed. Only timed output
62
+ // enters speed figures.
63
+ requestMs,
63
64
  // A provider that cannot measure the prompt or its cache split says so;
64
65
  // its zeros are unknowns, not readings (no cache hit can be derived).
65
66
  promptKnown: usage.inputTokensKnown !== false && usage.cacheTokensKnown !== false,
@@ -79,6 +80,47 @@ export function usageDeltaEvent({
79
80
  };
80
81
  }
81
82
 
83
+ const SUMMED_USAGE_FIELDS = [
84
+ 'inputTokens',
85
+ 'outputTokens',
86
+ 'cachedTokens',
87
+ 'cacheWriteTokens',
88
+ 'cacheWrite1hTokens',
89
+ 'promptTokens',
90
+ ];
91
+
92
+ /** What `total` added after the running total `earlier` (both from addUsage); null when nothing. */
93
+ export function usageSince(total, earlier) {
94
+ if (!total) return null;
95
+ if (!earlier) return { ...total };
96
+ const delta = {};
97
+ for (const field of SUMMED_USAGE_FIELDS) delta[field] = Math.max(0, (total[field] || 0) - (earlier[field] || 0));
98
+ if (total.reasoningTokens != null) {
99
+ delta.reasoningTokens = Math.max(0, total.reasoningTokens - (earlier.reasoningTokens ?? 0));
100
+ delta.reasoningTokensComplete = total.reasoningTokensComplete === true;
101
+ }
102
+ if (total.costUsd != null) delta.costUsd = Math.max(0, total.costUsd - (earlier.costUsd || 0));
103
+ if (total.contextTokens) delta.contextTokens = total.contextTokens;
104
+ return Object.values(delta).some((value) => typeof value === 'number' && value > 0) ? delta : null;
105
+ }
106
+
107
+ /**
108
+ * The turn's usage split per model when the route switched mid-turn:
109
+ * `hops` holds, per left model, the running total when it was left. Each
110
+ * segment carries only its own tokens so it is priced at its own rates.
111
+ */
112
+ export function routeUsageSegments(hops, total, current) {
113
+ if (!Array.isArray(hops) || !hops.length) return null;
114
+ const boundaries = [...hops, { ...current, usage: total }];
115
+ return boundaries
116
+ .map((segment, index) => ({
117
+ provider: segment.provider,
118
+ model: segment.model,
119
+ usage: usageSince(segment.usage, index ? boundaries[index - 1].usage : null),
120
+ }))
121
+ .filter((segment) => segment.usage);
122
+ }
123
+
82
124
  export function addUsage(total, usage) {
83
125
  const delta = normalizeUsage(usage);
84
126
  if (!delta) return total;
@@ -120,14 +120,24 @@ function recordAgentRuntimeCall(session, result) {
120
120
  export async function recordAskUsage({ session, result, askStartedAt, turnSignal }) {
121
121
  const prefixHashForLog = recordAgentRuntimeCall(session, result);
122
122
  if (!result.usage) return;
123
- await logSessionLlmUsage({
124
- session,
125
- usage: result.usage,
126
- startedAt: askStartedAt,
127
- signal: turnSignal,
128
- prefixHash: prefixHashForLog,
129
- transportTiming: result.transportTiming || null,
130
- });
123
+ // A turn that switched models mid-way books one row per model, each priced
124
+ // at that model's rates; otherwise one row for the whole turn.
125
+ const segments = result.usageSegments?.length
126
+ ? result.usageSegments
127
+ : [{ provider: session.provider, model: session.model, usage: result.usage }];
128
+ for (const segment of segments) {
129
+ await logSessionLlmUsage({
130
+ session:
131
+ segment.provider === session.provider && segment.model === session.model
132
+ ? session
133
+ : { ...session, provider: segment.provider, model: segment.model },
134
+ usage: segment.usage,
135
+ startedAt: askStartedAt,
136
+ signal: turnSignal,
137
+ prefixHash: prefixHashForLog,
138
+ transportTiming: result.transportTiming || null,
139
+ });
140
+ }
131
141
  recordStandaloneStatusTelemetry(session, result, Date.now() - askStartedAt);
132
142
  }
133
143
 
@@ -0,0 +1,50 @@
1
+ // Provider-native replay state on transcript messages: reasoning items,
2
+ // thinking blocks with their signatures, ordered native blocks, Gemini thought
3
+ // signatures on tool calls. It belongs to the provider (and the account) that
4
+ // produced it — another provider rejects it or, worse, a gateway of the same
5
+ // family validates it against its own key. A route change drops it; what the
6
+ // next provider needs (text, tool calls and their results, in order) stays,
7
+ // and each adapter rebuilds its own wire shape from that.
8
+
9
+ // Every message field a provider adapter replays or re-reads as native state.
10
+ const PROVIDER_REPLAY_FIELDS = [
11
+ 'providerReplay',
12
+ 'reasoning',
13
+ 'thinkingBlocks',
14
+ 'assistantBlocks',
15
+ 'reasoningItems',
16
+ 'reasoningContent',
17
+ 'providerMetadata',
18
+ ];
19
+ // Opaque provider tokens a tool call may carry (Gemini thought signatures).
20
+ const TOOL_CALL_SIGNATURE_FIELDS = ['thoughtSignature', 'thought_signature'];
21
+
22
+ const hasAny = (value, fields) => fields.some((field) => field in value);
23
+
24
+ function plainToolCalls(toolCalls) {
25
+ if (!Array.isArray(toolCalls) || !toolCalls.some((call) => call && hasAny(call, TOOL_CALL_SIGNATURE_FIELDS))) {
26
+ return toolCalls;
27
+ }
28
+ return toolCalls.map((call) => {
29
+ if (!call || !hasAny(call, TOOL_CALL_SIGNATURE_FIELDS)) return call;
30
+ const plain = { ...call };
31
+ for (const field of TOOL_CALL_SIGNATURE_FIELDS) delete plain[field];
32
+ return plain;
33
+ });
34
+ }
35
+
36
+ /** `message` without provider-native replay state; the same object when it carries none. */
37
+ export function stripProviderReplayMessage(message) {
38
+ if (!message || typeof message !== 'object') return message;
39
+ const toolCalls = plainToolCalls(message.toolCalls);
40
+ if (!hasAny(message, PROVIDER_REPLAY_FIELDS) && toolCalls === message.toolCalls) return message;
41
+ const plain = { ...message };
42
+ for (const field of PROVIDER_REPLAY_FIELDS) delete plain[field];
43
+ if (toolCalls !== message.toolCalls) plain.toolCalls = toolCalls;
44
+ return plain;
45
+ }
46
+
47
+ /** Replace, never mutate: delta saves see rewritten messages as changed. */
48
+ export function stripProviderReplayMessages(messages) {
49
+ return Array.isArray(messages) ? messages.map(stripProviderReplayMessage) : messages;
50
+ }
@@ -19,6 +19,7 @@ import {
19
19
  } from '../../agent-runtime/cache-strategy.mjs';
20
20
  import { normalizeAutoClearConfig, resolveAutoClearIdleMs } from '../../runtime-core/config-helpers.mjs';
21
21
  import { _buildBaseRules } from './rules-cache.mjs';
22
+ import { stripProviderReplayMessages } from './provider-replay.mjs';
22
23
  import { composeSessionSystem, seedSessionMessages } from './session-prompt-composition.mjs';
23
24
  import { agentProfileRevision, leadRosterRevision } from '../../context/role-instructions.mjs';
24
25
  import {
@@ -241,7 +242,9 @@ export function createSession(createOpts) {
241
242
  const surface = resolveSessionToolSurface(opts, { profile, toolPreset, modelName });
242
243
  // The agent profile the prompt is composed from, read first: an edit landing
243
244
  // during composition leaves the stamp behind, never ahead (refreshAgentProfile).
244
- const agentProfile = surface.ownerIsAgent ? '' : agentProfileRevision(surface.resolvedAgent);
245
+ const agentProfile = surface.ownerIsAgent
246
+ ? ''
247
+ : agentProfileRevision(surface.resolvedAgent, { mixy: sessionMode === AGENT_MODE });
245
248
  const prompt = composeSessionSystem(opts, { profile, providerName, modelName, surface, sessionMode });
246
249
  const messages = seedSessionMessages(prompt, opts.files);
247
250
  logSessionSurface(surface);
@@ -421,27 +424,11 @@ function resetSessionForRouteChange(id, session, previousModel, previousProvider
421
424
  _forgetPreparedResume(id);
422
425
  }
423
426
 
424
- // Every message field a provider adapter replays or re-reads as native state.
425
- const PROVIDER_REPLAY_FIELDS = [
426
- 'providerReplay',
427
- 'reasoning',
428
- 'thinkingBlocks',
429
- 'assistantBlocks',
430
- 'reasoningItems',
431
- 'reasoningContent',
432
- 'providerMetadata',
433
- ];
434
-
435
- // Provider-native replay (reasoning items, thinking blocks) belongs to the
436
- // provider that produced it. Replace, never mutate, so delta saves rewrite.
427
+ // Provider-native replay (reasoning items, thinking blocks, signatures)
428
+ // belongs to the provider that produced it (see provider-replay.mjs).
437
429
  function stripProviderReplay(session) {
438
430
  if (!Array.isArray(session.messages)) return;
439
- session.messages = session.messages.map((message) => {
440
- if (!message || !PROVIDER_REPLAY_FIELDS.some((field) => field in message)) return message;
441
- const plain = { ...message };
442
- for (const field of PROVIDER_REPLAY_FIELDS) delete plain[field];
443
- return plain;
444
- });
431
+ session.messages = stripProviderReplayMessages(session.messages);
445
432
  }
446
433
 
447
434
  // `options.crossProvider` is the explicit in-place continuation of an
@@ -139,6 +139,9 @@ export function sessionRolePromptFields(
139
139
  // session running as that agent) carries its own AGENT.md role, and Mixy
140
140
  // in agent mode its optional agents/lead/AGENT.md.
141
141
  skipRoleCatalog: !ownerIsAgent && (!identity.agent || identity.agent === 'lead') && !(agentMode && mixy),
142
+ // Mixy's persona (bots/mixy/PERSONA.md) is Mixy's alone: keyed on Mixy's
143
+ // own agent-mode thread, never on the `lead` role a coding Lead shares.
144
+ skipPersona: !ownerIsAgent && (!identity.agent || identity.agent === 'lead') && !(agentMode && mixy),
142
145
  agent: identity.agent,
143
146
  profile: { skip: identity.profileSkip },
144
147
  };
@@ -18,6 +18,7 @@ import {
18
18
  writeCheckpointHeader,
19
19
  } from './turn-checkpoint-journal.mjs';
20
20
  import { captureTurnCheckpointContextState, restoreTurnCheckpointContextState } from './turn-checkpoint-context.mjs';
21
+ import { stripProviderReplayMessages } from './provider-replay.mjs';
21
22
 
22
23
  export { turnCheckpointPath, turnMessagesForCheckpoint };
23
24
  export { captureTurnCheckpointContextState, restoreTurnCheckpointContextState };
@@ -198,7 +199,12 @@ export function recoverTurnCheckpoint(session) {
198
199
  const current = Array.isArray(session.messages) ? session.messages : [];
199
200
  const start = findTurnStart(current, checkpoint.currentUserContent);
200
201
  const kept = start >= 0 ? current.slice(0, start) : current;
201
- session.messages = checkpoint.fullTranscript === true ? finalized.messages : [...kept, ...finalized.messages];
202
+ // The route switched models mid-turn (the marker says so) and the journal
203
+ // may predate the switch: its turn still carries the previous provider's
204
+ // native replay state, which the saved route's provider must not receive.
205
+ const turnMessages =
206
+ marker?.routeChanged === true ? stripProviderReplayMessages(finalized.messages) : finalized.messages;
207
+ session.messages = checkpoint.fullTranscript === true ? turnMessages : [...kept, ...turnMessages];
202
208
  // Re-anchor provider usage / post-compact replacement state to the exact
203
209
  // canonical checkpoint representation. If a legacy or corrupted checkpoint
204
210
  // cannot prove alignment, retain the last provider reading for display but
@@ -558,7 +558,7 @@ export async function persistIterationMetrics(delta) {
558
558
  // per model call of this session (every tool-loop iteration), replaced
559
559
  // immutably so a consumer can tell by identity that it changed. Sessions
560
560
  // saved before this field began at their first call after it.
561
- session.usageReadout = foldUsageReadout(session.usageReadout, delta, delta.generationMs);
561
+ session.usageReadout = foldUsageReadout(session.usageReadout, delta, delta.requestMs);
562
562
  recordLastContextTokens(session, iterationContext(delta));
563
563
  }
564
564
  session.lastIterationIndex = iterationIndex;
@@ -4,8 +4,9 @@
4
4
  // use so a rename applies at once. Text shared across agents (tool schemas,
5
5
  // other agents' shared prompt block) says "the representative agent" instead,
6
6
  // so a rename never changes cached bytes.
7
- import { join } from 'node:path';
7
+ import { dirname, join } from 'node:path';
8
8
  import { mixdogGlobalDir, readSafe } from '../context/skill-catalog.mjs';
9
+ import { mixyIdentityDir } from '../../../shared/agent-store.mjs';
9
10
 
10
11
  export const DEFAULT_MIXY_NAME = 'Mixy';
11
12
 
@@ -17,7 +18,9 @@ export function mixyNameOf(manifest) {
17
18
 
18
19
  export function mixyDisplayName() {
19
20
  try {
20
- return mixyNameOf(JSON.parse(readSafe(join(mixdogGlobalDir('agents'), 'lead', 'agent.json')) || '{}'));
21
+ // bots/mixy once Mixy is its own bot, agents/lead before (agent-store.mjs).
22
+ const dir = mixyIdentityDir(dirname(mixdogGlobalDir('agents')));
23
+ return mixyNameOf(JSON.parse(readSafe(join(dir, 'agent.json')) || '{}'));
21
24
  } catch {
22
25
  return DEFAULT_MIXY_NAME;
23
26
  }
@@ -9,7 +9,7 @@ export interface RoutineProvenance {
9
9
  export interface RoutineMessageMeta {
10
10
  source: string;
11
11
  routine: Record<string, unknown>;
12
- transcript: { at: number; messageId?: string };
12
+ transcript: { at: number; messageId?: string; attachments?: unknown[] };
13
13
  }
14
14
  export interface RoutineMessage {
15
15
  role: 'user';
@@ -22,6 +22,15 @@ export interface RoutineNotice {
22
22
  routine: Record<string, unknown>;
23
23
  /** Live run notices only (routineRunNotice). */
24
24
  messageId?: string;
25
+ /** A task report's stored files (handoff-artifacts.mjs). */
26
+ attachments?: Array<{
27
+ id: string;
28
+ attachmentRef: string;
29
+ kind: string;
30
+ name: string;
31
+ mimeType: string;
32
+ size: number;
33
+ }>;
25
34
  }
26
35
 
27
36
  export const ROUTINE_RESULT_SOURCE: 'schedule-result';
@@ -45,7 +54,14 @@ export function agentTaskMessage(options: {
45
54
  status: string;
46
55
  text?: unknown;
47
56
  taskSessionId: string;
57
+ /** 'session': any session other than a task session the agent created. */
58
+ target?: 'task' | 'session';
48
59
  round?: number;
60
+ /** false: the report was posted without waking the agent (round-trip cap). */
61
+ woken?: boolean;
62
+ /** The files the turn produced (handoff-artifacts.mjs). */
63
+ files?: ReadonlyArray<{ name: string; path: string; mimeType?: string; size?: number; kind?: string; id?: string }>;
64
+ omitted?: number;
49
65
  at?: number;
50
66
  messageId?: string;
51
67
  }): RoutineMessage;
@@ -9,6 +9,8 @@
9
9
  // close the boundary or forge a reminder. Every transcript renders the row as
10
10
  // a notice: a routine result, never a user bubble or an assistant turn.
11
11
  import { escapeHarnessTags } from '../../../shared/shell-output-summary.mjs';
12
+ import { artifactLines, artifactRecords, normalizeHandoffFiles } from '../../../shared/handoff-artifacts.mjs';
13
+ import { transcriptAttachmentFields } from '../../../shared/transcript-metadata.mjs';
12
14
 
13
15
  export const ROUTINE_RESULT_SOURCE = 'schedule-result';
14
16
  const SUMMARY_LIMIT = 600;
@@ -135,10 +137,28 @@ export const TASK_LOOP_CAP = 3;
135
137
  * turn that ended `completed` / `failed` / `cancelled`. `round` counts the
136
138
  * agent-initiated round trips; from TASK_LOOP_CAP on, the report asks the
137
139
  * agent to check with the user before continuing. */
138
- export function agentTaskMessage({ title, status, text = '', taskSessionId, round = 0, at = Date.now(), messageId }) {
140
+ export function agentTaskMessage({
141
+ title,
142
+ status,
143
+ text = '',
144
+ taskSessionId,
145
+ target = 'task',
146
+ round = 0,
147
+ woken = true,
148
+ files = [],
149
+ omitted = 0,
150
+ at = Date.now(),
151
+ messageId,
152
+ }) {
139
153
  const summary = routineSummary(text);
154
+ // The files the turn produced: listed for the agent, stored ones carried as
155
+ // the row's attachments (shown with the report, forwardable by id).
156
+ const produced = normalizeHandoffFiles(files);
157
+ const attachments = artifactRecords(produced);
140
158
  const label = escapeHarnessTags(String(title).replace(/["\r\n]/g, ''));
141
- const head = `[mixdog-runtime] Coding task session "${label}" (${taskSessionId})`;
159
+ // `target` 'session': any session other than a task session the agent created.
160
+ const session = target === 'session';
161
+ const head = `[mixdog-runtime] ${session ? 'Session' : 'Agent task session'} "${label}" (${taskSessionId})`;
142
162
  let content;
143
163
  if (status === 'started') content = `${head} started; its result is reported here when its turn ends.`;
144
164
  else if (status === 'blocked') {
@@ -149,9 +169,13 @@ export function agentTaskMessage({ title, status, text = '', taskSessionId, roun
149
169
  `<task-output session="${taskSessionId}" status="${status}">`,
150
170
  escapeHarnessTags(summary),
151
171
  '</task-output>',
172
+ ...artifactLines(produced, omitted),
152
173
  ...(round >= TASK_LOOP_CAP
153
- ? [`This was round trip ${round} with this task session: check with the user before sending it more work.`]
174
+ ? [
175
+ `This was round trip ${round} with this ${session ? 'session' : 'agent task session'}: check with the user before sending it more work.`,
176
+ ]
154
177
  : []),
178
+ ...(woken ? [] : ['This report did not wake you: the round-trip cap with that agent thread is reached.']),
155
179
  ].join('\n');
156
180
  }
157
181
  return {
@@ -159,17 +183,33 @@ export function agentTaskMessage({ title, status, text = '', taskSessionId, roun
159
183
  content,
160
184
  meta: {
161
185
  source: ROUTINE_RESULT_SOURCE,
162
- routine: { kind: 'task', name: title, status, summary, sessionId: taskSessionId },
163
- transcript: { at, ...(messageId ? { messageId } : {}) },
186
+ routine: {
187
+ kind: 'task',
188
+ name: title,
189
+ status,
190
+ summary,
191
+ sessionId: taskSessionId,
192
+ ...(session ? { target: 'session' } : {}),
193
+ ...(produced.length ? { files: produced } : {}),
194
+ },
195
+ transcript: { at, ...(messageId ? { messageId } : {}), ...(attachments.length ? { attachments } : {}) },
164
196
  },
165
197
  };
166
198
  }
167
199
 
168
- /** The hidden prompt that wakes the agent once its task results are posted. */
169
- export function agentTaskWakePrompt(messages) {
200
+ /** The hidden prompt that wakes the agent once its task results are posted;
201
+ * `more` counts posted reports it carries only as rows in the thread. */
202
+ export function agentTaskWakePrompt(messages, { more = 0 } = {}) {
203
+ const files = messages.some((message) => message?.meta?.routine?.files?.length);
170
204
  return [
171
205
  ...messages.map((message) => message.content),
206
+ ...(more > 0 ? [`${more} more report${more === 1 ? '' : 's'} arrived; read the rows above.`] : []),
172
207
  'Relay the task result above to the user now, briefly and in your own words.',
208
+ ...(files
209
+ ? [
210
+ 'Its files are already shown to the user with the report; name the ones that matter, link a path as a markdown link only when you refer to it, and pass them on to another session or agent with attachments.',
211
+ ]
212
+ : []),
173
213
  ].join('\n\n');
174
214
  }
175
215
 
@@ -218,11 +258,23 @@ export function routineResultNotice(message) {
218
258
  }
219
259
  if (routine.kind === 'task') {
220
260
  const { name, status, summary, sessionId } = routine;
261
+ const session = routine.target === 'session';
221
262
  const what = { started: 'started', blocked: 'waiting for confirmation' }[status] || status;
263
+ const files = normalizeHandoffFiles(routine.files);
222
264
  return {
223
- text: `🛠 Task ${what} · ${name}${summary ? ` — ${summary}` : ''}`,
265
+ text: `🛠 ${session ? 'Session' : 'Agent task'} ${what} · ${name}${summary ? ` — ${summary}` : ''}`,
224
266
  tone: status === 'failed' ? 'warn' : 'info',
225
- routine: { kind: 'task', name, status, summary, sessionId },
267
+ routine: {
268
+ kind: 'task',
269
+ name,
270
+ status,
271
+ summary,
272
+ sessionId,
273
+ ...(session ? { target: 'session' } : {}),
274
+ ...(files.length ? { files } : {}),
275
+ },
276
+ // The stored files, in the field a prompt row draws its attachments from.
277
+ ...transcriptAttachmentFields(message.meta.transcript),
226
278
  };
227
279
  }
228
280
  const webhook = routine.kind === 'webhook';
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { contextMeasurementStats } from '../../../../shared/context-measurement.mjs';
8
8
  import { toolResultText } from './tool-result-text.mjs';
9
- import { restoredUserImageRefs, restoredUserText } from './user-text.mjs';
9
+ import { createUserTextRestore } from './user-text.mjs';
10
10
  import {
11
11
  completionCardFromExecution,
12
12
  parseModelVisibleCompletionWrapper,
@@ -379,7 +379,7 @@ function restoredNotificationToolItem(parsed, text, message, nextId, errorLabel)
379
379
  };
380
380
  }
381
381
 
382
- function restoredUserTranscriptItems(message, nextId) {
382
+ function restoredUserTranscriptItems(message, nextId, userText) {
383
383
  // Injected model-context payloads are model-visible but never user-authored:
384
384
  // skill bodies (meta:'skill'), hook/system reminders, and tag-wrapped context
385
385
  // blocks must not restore as user bubbles in any client.
@@ -403,7 +403,7 @@ function restoredUserTranscriptItems(message, nextId) {
403
403
  const text = (typeof message.content === 'string' ? message.content : toolResultText(message.content)).trim();
404
404
  return text ? [{ kind: 'user', id: nextId(), text, ...restoredTranscriptMetadata(message) }] : [];
405
405
  }
406
- const text = restoredUserText(message?.content).trim();
406
+ const text = userText.text(message?.content).trim();
407
407
  // Persisted async-completion wrappers ("Async shell task ... finished. Result:
408
408
  // > ...") are the only durable record of a background completion — the live
409
409
  // Response card is an event-time push that does not survive a transcript
@@ -434,7 +434,7 @@ function restoredUserTranscriptItems(message, nextId) {
434
434
  }
435
435
  const synthetic = parseSyntheticAgentMessage(text);
436
436
  if (!synthetic) {
437
- const imageRefs = restoredUserImageRefs(message?.content);
437
+ const imageRefs = userText.imageRefs(message?.content);
438
438
  return [
439
439
  {
440
440
  kind: 'user',
@@ -442,6 +442,8 @@ function restoredUserTranscriptItems(message, nextId) {
442
442
  text,
443
443
  ...restoredTranscriptMetadata(message),
444
444
  ...(imageRefs.length ? { imageRefs } : {}),
445
+ // Read by the model inside its running turn, as the live row says.
446
+ ...(message?.meta?.source === 'steering' ? { steered: true } : {}),
445
447
  },
446
448
  ];
447
449
  }
@@ -474,7 +476,7 @@ export function workflowSwitchItem(id, boundary) {
474
476
  };
475
477
  }
476
478
 
477
- function restoreTranscriptRange(messages, start, sessionId) {
479
+ function restoreTranscriptRange(messages, start, sessionId, userText) {
478
480
  const items = [];
479
481
  const pendingToolCalls = new Map();
480
482
  for (let index = start; index < messages.length; index += 1) {
@@ -490,7 +492,7 @@ function restoreTranscriptRange(messages, start, sessionId) {
490
492
  continue;
491
493
  }
492
494
  if (message?.role === 'user') {
493
- items.push(...restoredUserTranscriptItems(message, restoredId));
495
+ items.push(...restoredUserTranscriptItems(message, restoredId, userText));
494
496
  } else if (message?.role === 'assistant' && !isContextAckMessage(message, messages[index - 1])) {
495
497
  items.push(...restoredAssistantTranscriptItems(message, restoredId));
496
498
  items.push(...restoredToolCallItems(message, restoredId, pendingToolCalls));
@@ -533,9 +535,12 @@ function restoreTranscriptRange(messages, start, sessionId) {
533
535
 
534
536
  export function restoreTranscriptItems(messages, { sessionId = 'session', itemLimit = Number.POSITIVE_INFINITY } = {}) {
535
537
  const source = Array.isArray(messages) ? messages : [];
538
+ // One projection per load: a widened range reuses each prompt's parts and
539
+ // the paste-line reads share one budget.
540
+ const userText = createUserTextRestore();
536
541
  const numericLimit = Number(itemLimit);
537
542
  const limited = Number.isFinite(numericLimit) && numericLimit > 0;
538
- if (!limited) return restoreTranscriptRange(source, 0, sessionId);
543
+ if (!limited) return restoreTranscriptRange(source, 0, sessionId, userText);
539
544
 
540
545
  const limit = Math.max(1, Math.floor(numericLimit));
541
546
  // Restore beyond the visible cap so a boundary tool run can merge exactly
@@ -549,7 +554,7 @@ export function restoreTranscriptItems(messages, { sessionId = 'session', itemLi
549
554
  upperBound += restoredMessageItemUpperBound(source[start]);
550
555
  }
551
556
 
552
- let restored = restoreTranscriptRange(source, start, sessionId);
557
+ let restored = restoreTranscriptRange(source, start, sessionId, userText);
553
558
  // Hidden/context messages and aggregate tool runs can make the cheap upper
554
559
  // bound optimistic. Expand backward exponentially only when necessary.
555
560
  let expansionTarget = Math.max(64, limit - restored.length);
@@ -559,7 +564,7 @@ export function restoreTranscriptItems(messages, { sessionId = 'session', itemLi
559
564
  start -= 1;
560
565
  expansion += restoredMessageItemUpperBound(source[start]);
561
566
  }
562
- restored = restoreTranscriptRange(source, start, sessionId);
567
+ restored = restoreTranscriptRange(source, start, sessionId, userText);
563
568
  expansionTarget *= 2;
564
569
  }
565
570
  return restored.length > limit ? restored.slice(-limit) : restored;
@@ -116,14 +116,64 @@ function referencedFeedbackText(part) {
116
116
  }
117
117
  }
118
118
 
119
+ const PASTED_LINES = /· (\d+) lines?$/;
120
+ const PASTED_TEXT_MAX_BYTES = 1024 * 1024;
121
+ /** Blob bytes one transcript load may read to count the lines of pastes
122
+ * whose stored name does not carry the count (pastes stored before it did). */
123
+ export const PASTED_LINE_READ_BUDGET_BYTES = 4 * 1024 * 1024;
124
+
125
+ /** Visible lines of a stored paste (as `pastedTextLineCount` counts them), or
126
+ * null when the count would take a read past the load's budget. */
127
+ function storedPasteLines(part, budget) {
128
+ const named = PASTED_LINES.exec(String(part.filename || ''));
129
+ if (named) return Number(named[1]);
130
+ const allowance = Math.min(PASTED_TEXT_MAX_BYTES, budget.remaining);
131
+ if (allowance <= 0) return null;
132
+ try {
133
+ const read = readAttachmentTextWithin(part, allowance);
134
+ if (read == null) return null;
135
+ budget.remaining -= Buffer.byteLength(read, 'utf8');
136
+ const value = read.replace(/\r\n?/g, '\n').replace(/\n+$/, '');
137
+ return value ? value.split('\n').length : 0;
138
+ } catch (error) {
139
+ // A blob that failed its integrity check was read before it failed: the
140
+ // read counts against the cap (at most the allowance). A missing blob
141
+ // read nothing.
142
+ if (error?.code !== 'ENOENT') budget.remaining -= allowance;
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /** The chip token a stored pasted / attached text stood for in the live
148
+ * bubble (intake moved its body into the store and dropped the token). A
149
+ * paste whose line count is unknown shows as the plain `[Pasted text #n]`. */
150
+ function storedTextToken(part, number, budget) {
151
+ if (part?.type !== 'text') return '';
152
+ if (part.textKind === 'paste') {
153
+ const lines = storedPasteLines(part, budget);
154
+ return lines == null ? `[Pasted text #${number}]` : `[Pasted text #${number} +${lines} lines]`;
155
+ }
156
+ if (part.textKind === 'file') return `[File #${number}: ${part.filename || 'attachment.txt'}]`;
157
+ return '';
158
+ }
159
+
119
160
  /** The display lines of a stored prompt's parts, and for each image part the
120
161
  * line that draws it: the composer's metadata line right before it, else a
121
- * bare line of its own (a bare line never stands for the next image). */
122
- function restoredUserParts(content) {
162
+ * bare line of its own (a bare line never stands for the next image). A
163
+ * stored pasted or attached text shows as its chip token. */
164
+ function restoredUserParts(content, budget) {
123
165
  const texts = [];
124
166
  const images = [];
125
167
  let pendingMetadata = '';
168
+ let storedTexts = 0;
126
169
  for (const part of content) {
170
+ const token = isAttachmentReference(part) ? storedTextToken(part, storedTexts + 1, budget) : '';
171
+ if (token) {
172
+ storedTexts += 1;
173
+ texts.push(token);
174
+ pendingMetadata = '';
175
+ continue;
176
+ }
127
177
  if (part?.type === 'image' || part?.type === 'input_image') {
128
178
  let line = pendingMetadata;
129
179
  if (!line) {
@@ -141,21 +191,48 @@ function restoredUserParts(content) {
141
191
  return { texts, images };
142
192
  }
143
193
 
194
+ /**
195
+ * The user-bubble projection of one transcript load: each stored prompt's
196
+ * parts are worked out once (its text and its image refs share them, and a
197
+ * load that widens its range sees the same messages again), and the blob
198
+ * reads that count paste lines share one byte budget.
199
+ */
200
+ export function createUserTextRestore({ readBudgetBytes = PASTED_LINE_READ_BUDGET_BYTES } = {}) {
201
+ const budget = { remaining: readBudgetBytes };
202
+ const parts = new WeakMap();
203
+ const partsOf = (content) => {
204
+ let found = parts.get(content);
205
+ if (!found) {
206
+ found = restoredUserParts(content, budget);
207
+ parts.set(content, found);
208
+ }
209
+ return found;
210
+ };
211
+ return {
212
+ text(content) {
213
+ if (!Array.isArray(content)) {
214
+ return withVisualizeFeedbackDisplay(typeof content === 'string' ? content : toolResultText(content));
215
+ }
216
+ return withVisualizeFeedbackDisplay(partsOf(content).texts.join('\n'));
217
+ },
218
+ /** The stored original of each image the restored bubble draws, by the
219
+ * `[Image: …]` line that draws it. Every image keeps its slot (one
220
+ * without a stored original has no `ref`) so later images do not shift
221
+ * onto it; empty when no image has a stored original. */
222
+ imageRefs(content) {
223
+ if (!Array.isArray(content)) return [];
224
+ const slots = partsOf(content).images.map(({ part, line }) =>
225
+ isAttachmentReference(part) ? { line, ref: part.attachmentRef } : { line }
226
+ );
227
+ return slots.some((slot) => slot.ref) ? slots : [];
228
+ },
229
+ };
230
+ }
231
+
144
232
  export function restoredUserText(content) {
145
- if (!Array.isArray(content)) {
146
- return withVisualizeFeedbackDisplay(typeof content === 'string' ? content : toolResultText(content));
147
- }
148
- return withVisualizeFeedbackDisplay(restoredUserParts(content).texts.join('\n'));
233
+ return createUserTextRestore().text(content);
149
234
  }
150
235
 
151
- /** The stored original of each image the restored bubble draws, by the
152
- * `[Image: …]` line that draws it. Every image keeps its slot (one without a
153
- * stored original has no `ref`) so later images do not shift onto it; empty
154
- * when no image has a stored original. */
155
236
  export function restoredUserImageRefs(content) {
156
- if (!Array.isArray(content)) return [];
157
- const slots = restoredUserParts(content).images.map(({ part, line }) =>
158
- isAttachmentReference(part) ? { line, ref: part.attachmentRef } : { line }
159
- );
160
- return slots.some((slot) => slot.ref) ? slots : [];
237
+ return createUserTextRestore().imageRefs(content);
161
238
  }