ethagent 2.4.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +7 -4
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +155 -15
  4. package/src/app/FirstRunTimeline.tsx +4 -0
  5. package/src/app/input/AppInputProvider.tsx +19 -0
  6. package/src/app/input/appInputParser.ts +19 -4
  7. package/src/chat/ChatBottomPane.tsx +3 -1
  8. package/src/chat/ChatScreen.tsx +7 -1
  9. package/src/chat/ConversationStack.tsx +25 -19
  10. package/src/chat/MessageList.tsx +194 -53
  11. package/src/chat/chatSessionState.ts +1 -1
  12. package/src/chat/chatTurnOrchestrator.ts +59 -0
  13. package/src/chat/input/ChatInput.tsx +3 -0
  14. package/src/chat/input/textCursor.ts +13 -3
  15. package/src/chat/transcript/TranscriptView.tsx +7 -5
  16. package/src/chat/transcript/transcriptViewport.ts +88 -17
  17. package/src/chat/views/PermissionPrompt.tsx +26 -26
  18. package/src/chat/views/PermissionsView.tsx +18 -12
  19. package/src/chat/views/RewindView.tsx +3 -1
  20. package/src/cli/ResetConfirmView.tsx +24 -9
  21. package/src/identity/continuity/editor.ts +27 -2
  22. package/src/identity/continuity/envelope.ts +125 -0
  23. package/src/identity/continuity/publicSkills.ts +37 -1
  24. package/src/identity/continuity/skills/frontmatter.ts +183 -0
  25. package/src/identity/continuity/skills/loadSkills.ts +609 -0
  26. package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
  27. package/src/identity/continuity/skills/scaffold.ts +52 -0
  28. package/src/identity/continuity/skills/types.ts +30 -0
  29. package/src/identity/continuity/storage/defaults.ts +28 -47
  30. package/src/identity/continuity/storage/files.ts +1 -0
  31. package/src/identity/continuity/storage/paths.ts +1 -0
  32. package/src/identity/continuity/storage/scaffold.ts +25 -23
  33. package/src/identity/continuity/storage/status.ts +34 -5
  34. package/src/identity/continuity/storage/types.ts +3 -2
  35. package/src/identity/continuity/storage.ts +3 -0
  36. package/src/identity/hub/OperationalRoutes.tsx +105 -3
  37. package/src/identity/hub/Routes.tsx +5 -3
  38. package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +5 -51
  39. package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +1 -1
  40. package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -0
  41. package/src/identity/hub/continuity/effects.ts +36 -5
  42. package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
  43. package/src/identity/hub/continuity/skills/DeleteSkillScreen.tsx +123 -0
  44. package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
  45. package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
  46. package/src/identity/hub/continuity/skills/SkillVisibilityScreen.tsx +171 -0
  47. package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +213 -0
  48. package/src/identity/hub/continuity/snapshot.ts +3 -0
  49. package/src/identity/hub/continuity/state.ts +3 -2
  50. package/src/identity/hub/continuity/vault.ts +42 -10
  51. package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
  52. package/src/identity/hub/identityHubReducer.ts +21 -0
  53. package/src/identity/hub/profile/effects.ts +16 -3
  54. package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
  55. package/src/identity/hub/restore/apply.ts +12 -1
  56. package/src/identity/hub/restore/recovery.ts +11 -1
  57. package/src/identity/hub/restore/resolve.ts +1 -1
  58. package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
  59. package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
  60. package/src/identity/hub/shared/components/IdentitySummary.tsx +97 -53
  61. package/src/identity/hub/shared/components/MenuScreen.tsx +18 -15
  62. package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +1 -1
  63. package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
  64. package/src/identity/hub/shared/effects/sync.ts +16 -3
  65. package/src/identity/hub/shared/model/copy.ts +2 -4
  66. package/src/identity/hub/transfer/effects.ts +15 -2
  67. package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
  68. package/src/identity/hub/useIdentityHubController.ts +5 -1
  69. package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
  70. package/src/mcp/manager.ts +1 -1
  71. package/src/models/ModelPicker.tsx +89 -84
  72. package/src/models/llamacpp.ts +160 -11
  73. package/src/models/llamacppPreflight.ts +1 -16
  74. package/src/models/modelPickerOptions.ts +43 -37
  75. package/src/providers/contracts.ts +1 -0
  76. package/src/providers/openai-chat.ts +50 -9
  77. package/src/providers/openai-responses.ts +19 -4
  78. package/src/runtime/toolExecution.ts +4 -3
  79. package/src/runtime/turn.ts +61 -30
  80. package/src/tools/changeDirectoryTool.ts +1 -1
  81. package/src/tools/contracts.ts +10 -0
  82. package/src/tools/deleteFileTool.ts +1 -1
  83. package/src/tools/editTool.ts +1 -1
  84. package/src/tools/listDirectoryTool.ts +1 -1
  85. package/src/tools/listSkillFilesTool.ts +77 -0
  86. package/src/tools/listSkillsTool.ts +68 -0
  87. package/src/tools/mcpResourceTools.ts +2 -2
  88. package/src/tools/privateContinuityReadTool.ts +1 -1
  89. package/src/tools/readSkillTool.ts +107 -0
  90. package/src/tools/readTool.ts +1 -1
  91. package/src/tools/registry.ts +6 -0
  92. package/src/tools/writeFileTool.ts +22 -2
  93. package/src/ui/Spinner.tsx +1 -1
  94. package/src/identity/continuity/localBackup.ts +0 -249
  95. package/src/identity/continuity/zipWriter.ts +0 -95
  96. package/src/identity/hub/continuity/index.ts +0 -7
  97. package/src/identity/hub/ens/index.ts +0 -11
  98. package/src/identity/hub/restore/index.ts +0 -22
@@ -26,9 +26,6 @@ export type LlamaCppPreflightDeps = {
26
26
  timeoutMs?: number
27
27
  }
28
28
 
29
- const UNTRACKED_VISION_DETAIL =
30
- 'A llama-server is already serving this alias but ethagent did not launch it, so we cannot apply the vision projector. Stop the external process and reopen ethagent.'
31
-
32
29
  type ModelsProbe =
33
30
  | { up: true; models: string[] }
34
31
  | { up: false; models: [] }
@@ -68,19 +65,7 @@ export async function ensureLlamaCppRunnerReady(
68
65
  }
69
66
  }
70
67
  if (!local.mmprojPath) return { ok: true, alreadyRunning: true }
71
- const stopped = await (deps.stopServer ?? stopLlamaCppServer)().catch(() => null)
72
- if (stopped && stopped.ok && stopped.reason === 'untracked-server') {
73
- return withPreflightMessage(
74
- {
75
- ok: false,
76
- code: 'untracked-server',
77
- message: UNTRACKED_VISION_DETAIL,
78
- detail: UNTRACKED_VISION_DETAIL,
79
- servedModels: stopped.servedModels,
80
- },
81
- local,
82
- )
83
- }
68
+ await (deps.stopServer ?? stopLlamaCppServer)().catch(() => null)
84
69
  }
85
70
 
86
71
  const result = await (deps.startServer ?? startLlamaCppServer)({
@@ -61,7 +61,9 @@ const CHILD_INDENT = 4
61
61
  export function buildModelPickerOptions(
62
62
  data: ModelPickerOptionsData,
63
63
  context: ModelPickerOptionsContext,
64
+ options_: { localOnly?: boolean } = {},
64
65
  ): SelectOption<string>[] {
66
+ const localOnly = options_.localOnly === true
65
67
  const options: SelectOption<string>[] = []
66
68
 
67
69
  options.push(sectionOption('hdr:local', 'Local Models'))
@@ -72,49 +74,53 @@ export function buildModelPickerOptions(
72
74
  options.push(utilityOption('local:uninstall', 'Uninstall Downloaded GGUF'))
73
75
  }
74
76
 
75
- options.push(sectionOption('hdr:cloud', 'Cloud'))
76
- for (const provider of MODEL_PICKER_CLOUD_PROVIDERS) {
77
- options.push(groupOption(`hdr:cloud:${provider}`, cloudProviderDisplayName(provider)))
78
- const keySet = data.cloudKeys[provider] === true
79
- if (!keySet) {
80
- if (provider === 'openai') {
81
- options.push(utilityOption('oauth:openai', 'Sign in with ChatGPT', 'Use your ChatGPT subscription'))
77
+ if (!localOnly) {
78
+ options.push(sectionOption('hdr:cloud', 'Cloud'))
79
+ for (const provider of MODEL_PICKER_CLOUD_PROVIDERS) {
80
+ options.push(groupOption(`hdr:cloud:${provider}`, cloudProviderDisplayName(provider)))
81
+ const keySet = data.cloudKeys[provider] === true
82
+ if (!keySet) {
83
+ if (provider === 'openai') {
84
+ options.push(utilityOption('oauth:openai', 'Sign in with ChatGPT', 'Use your ChatGPT subscription'))
85
+ }
86
+ options.push(utilityOption(`key:set:${provider}`, 'Add API Key'))
87
+ continue
82
88
  }
83
- options.push(utilityOption(`key:set:${provider}`, 'Add API Key'))
84
- continue
85
- }
86
89
 
87
- const catalog = data.cloudCatalogs[provider]
88
- if (catalog?.status === 'fallback') {
89
- const reason = catalog.error ? ` · ${catalog.error}` : ''
90
- options.push(noticeOption(
91
- `hdr:cloud-fallback:${provider}`,
92
- `Catalog unavailable${reason} · showing configured model`,
93
- CHILD_INDENT,
94
- ))
95
- }
90
+ const catalog = data.cloudCatalogs[provider]
91
+ if (catalog?.status === 'fallback') {
92
+ const reason = catalog.error ? ` · ${catalog.error}` : ''
93
+ options.push(noticeOption(
94
+ `hdr:cloud-fallback:${provider}`,
95
+ `Catalog unavailable${reason} · showing configured model`,
96
+ CHILD_INDENT,
97
+ ))
98
+ }
96
99
 
97
- const models = orderModelsForContextFit(provider, cloudPickerModels(provider, catalog, context), context.contextFit)
98
- if (models.length === 0) {
99
- options.push(noticeOption(`hdr:cloud-empty:${provider}`, 'No selectable models', CHILD_INDENT))
100
- }
101
- for (const model of models) {
102
- const active = context.currentProvider === provider && context.currentModel === model
103
- const displayName = formatModelDisplayName(provider, model, { maxLength: 58 })
104
- options.push(rowOption(
105
- `c:${provider}:${model}`,
106
- contextFitLabel(provider, model, `${displayName}${active ? ' *' : ''}`, context.contextFit),
107
- ))
100
+ const models = orderModelsForContextFit(provider, cloudPickerModels(provider, catalog, context), context.contextFit)
101
+ if (models.length === 0) {
102
+ options.push(noticeOption(`hdr:cloud-empty:${provider}`, 'No selectable models', CHILD_INDENT))
103
+ }
104
+ for (const model of models) {
105
+ const active = context.currentProvider === provider && context.currentModel === model
106
+ const displayName = formatModelDisplayName(provider, model, { maxLength: 58 })
107
+ options.push(rowOption(
108
+ `c:${provider}:${model}`,
109
+ contextFitLabel(provider, model, `${displayName}${active ? ' *' : ''}`, context.contextFit),
110
+ ))
111
+ }
112
+ options.push(utilityOption(`catalog:${provider}`, 'Full Catalog'))
113
+ const manageLabel = provider === 'openai' && data.cloudCredentialKinds?.openai === 'oauth'
114
+ ? 'Manage ChatGPT Sign-in'
115
+ : 'Manage API Key'
116
+ options.push(utilityOption(`key:manage:${provider}`, manageLabel))
108
117
  }
109
- options.push(utilityOption(`catalog:${provider}`, 'Full Catalog'))
110
- const manageLabel = provider === 'openai' && data.cloudCredentialKinds?.openai === 'oauth'
111
- ? 'Manage ChatGPT Sign-in'
112
- : 'Manage API Key'
113
- options.push(utilityOption(`key:manage:${provider}`, manageLabel))
114
118
  }
115
119
 
116
- options.push(sectionOption('hdr:exit', 'Exit'))
117
- options.push(utilityOption('cancel', 'Close Model Picker', 'Return to chat without changing model'))
120
+ if (!localOnly) {
121
+ options.push(sectionOption('hdr:exit', 'Exit'))
122
+ options.push(utilityOption('cancel', 'Close Model Picker', 'Return to chat'))
123
+ }
118
124
 
119
125
  return options
120
126
  }
@@ -42,6 +42,7 @@ export type ProviderRetryStreamEvent = { type: 'retry' } & RetryEvent
42
42
  export type StreamEvent =
43
43
  | { type: 'text'; delta: string }
44
44
  | { type: 'thinking'; delta: string }
45
+ | { type: 'thinking_end' }
45
46
  | ProviderRetryStreamEvent
46
47
  | { type: 'tool_use_start'; id: string; name: string }
47
48
  | { type: 'tool_use_delta'; id: string; delta: string }
@@ -171,6 +171,7 @@ export class OpenAIChatProvider implements Provider {
171
171
  let stopReason: DoneStopReason = 'unknown'
172
172
  const toolCalls = new Map<number, StreamingToolCall>()
173
173
  const contentThinkingParser = new ContentThinkingParser(this.id)
174
+ let reasoningPending = false
174
175
 
175
176
  try {
176
177
  for await (const frame of iterSseFrames(response.body, signal, READ_TIMEOUT_MS)) {
@@ -194,18 +195,34 @@ export class OpenAIChatProvider implements Provider {
194
195
  ? delta.thinking
195
196
  : ''
196
197
 
197
- if (reasoning.length > 0) yield { type: 'thinking', delta: reasoning }
198
+ if (reasoning.length > 0) {
199
+ yield { type: 'thinking', delta: reasoning }
200
+ reasoningPending = true
201
+ }
198
202
  if (text.length > 0) {
203
+ if (reasoningPending) {
204
+ yield { type: 'thinking_end' }
205
+ reasoningPending = false
206
+ }
199
207
  for (const event of contentThinkingParser.push(text)) {
200
208
  yield event
201
209
  }
202
210
  }
203
211
 
204
- for (const event of applyStreamingToolCallDelta(toolCalls, delta?.tool_calls ?? [])) {
212
+ const toolCallDeltas = delta?.tool_calls ?? []
213
+ if (toolCallDeltas.length > 0 && reasoningPending) {
214
+ yield { type: 'thinking_end' }
215
+ reasoningPending = false
216
+ }
217
+ for (const event of applyStreamingToolCallDelta(toolCalls, toolCallDeltas)) {
205
218
  yield event
206
219
  }
207
220
 
208
221
  if (choice?.finish_reason) {
222
+ if (reasoningPending) {
223
+ yield { type: 'thinking_end' }
224
+ reasoningPending = false
225
+ }
209
226
  stopReason = normalizeFinishReason(choice.finish_reason)
210
227
  }
211
228
  if (parsed.usage) {
@@ -223,6 +240,10 @@ export class OpenAIChatProvider implements Provider {
223
240
  for (const event of contentThinkingParser.flush()) {
224
241
  yield event
225
242
  }
243
+ if (reasoningPending) {
244
+ yield { type: 'thinking_end' }
245
+ reasoningPending = false
246
+ }
226
247
 
227
248
  let streamEmittedToolUses = 0
228
249
  if (stopReason === 'tool_use' || toolCalls.size > 0) {
@@ -383,17 +404,35 @@ function isToolResultBlock(block: MessageContentBlock): block is Extract<Message
383
404
 
384
405
  function parseToolArguments(inputJson: string): Record<string, unknown> {
385
406
  if (!inputJson.trim()) return {}
407
+ const direct = tryParseJsonOnce(inputJson)
408
+ if (direct !== undefined) return coerceToToolArguments(direct)
409
+ const repaired = repairJsonObject(inputJson)
410
+ if (!repaired) return {}
411
+ const parsedRepaired = tryParseJsonOnce(repaired)
412
+ return parsedRepaired === undefined ? {} : coerceToToolArguments(parsedRepaired)
413
+ }
414
+
415
+ function tryParseJsonOnce(value: string): unknown {
386
416
  try {
387
- return JSON.parse(inputJson) as Record<string, unknown>
417
+ return JSON.parse(value)
388
418
  } catch {
389
- const repaired = repairJsonObject(inputJson)
390
- if (!repaired) return {}
391
- try {
392
- return JSON.parse(repaired) as Record<string, unknown>
393
- } catch {
394
- return {}
419
+ return undefined
420
+ }
421
+ }
422
+
423
+ function coerceToToolArguments(value: unknown): Record<string, unknown> {
424
+ if (typeof value === 'string') {
425
+ const trimmed = value.trim()
426
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
427
+ const inner = tryParseJsonOnce(trimmed)
428
+ if (inner !== undefined) return coerceToToolArguments(inner)
395
429
  }
430
+ return {}
431
+ }
432
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
433
+ return value as Record<string, unknown>
396
434
  }
435
+ return {}
397
436
  }
398
437
 
399
438
  function* applyStreamingToolCallDelta(
@@ -486,7 +525,9 @@ class ContentThinkingParser {
486
525
  yield { type: this.state === 'thinking' ? 'thinking' : 'text', delta: before }
487
526
  }
488
527
  this.buffer = this.buffer.slice(tagIndex + tag.length)
528
+ const wasThinking = this.state === 'thinking'
489
529
  this.state = this.state === 'text' ? 'thinking' : 'text'
530
+ if (wasThinking) yield { type: 'thinking_end' }
490
531
  continue
491
532
  }
492
533
 
@@ -276,15 +276,30 @@ function parseToolArguments(input: string): Record<string, unknown> {
276
276
  const trimmed = input.trim()
277
277
  if (!trimmed) return {}
278
278
  try {
279
- const parsed = JSON.parse(trimmed) as unknown
280
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
281
- ? (parsed as Record<string, unknown>)
282
- : {}
279
+ return coerceToToolArguments(JSON.parse(trimmed))
283
280
  } catch {
284
281
  return {}
285
282
  }
286
283
  }
287
284
 
285
+ function coerceToToolArguments(value: unknown): Record<string, unknown> {
286
+ if (typeof value === 'string') {
287
+ const trimmed = value.trim()
288
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
289
+ try {
290
+ return coerceToToolArguments(JSON.parse(trimmed))
291
+ } catch {
292
+ return {}
293
+ }
294
+ }
295
+ return {}
296
+ }
297
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
298
+ return value as Record<string, unknown>
299
+ }
300
+ return {}
301
+ }
302
+
288
303
  function networkErrorMessage(baseUrl: string, err: unknown, fallback = 'network error'): string {
289
304
  const message = (err as Error).message || fallback
290
305
  return `openai request failed at ${baseUrl}: ${message}`
@@ -50,8 +50,8 @@ export async function executeToolWithPermissions(
50
50
  return {
51
51
  result: {
52
52
  ok: false,
53
- summary: `unknown tool ${options.name}`,
54
- content: `tool '${options.name}' is not registered`,
53
+ summary: `Unknown tool ${options.name}`,
54
+ content: `Tool '${options.name}' is not registered`,
55
55
  },
56
56
  }
57
57
  }
@@ -98,6 +98,7 @@ export async function executeToolWithPermissions(
98
98
  options.permissionMode === 'plan' &&
99
99
  request.kind !== 'read' &&
100
100
  request.kind !== 'private-continuity-read' &&
101
+ request.kind !== 'private-skill-read' &&
101
102
  !(request.kind === 'mcp' && request.readOnly)
102
103
  ) {
103
104
  return {
@@ -111,7 +112,7 @@ export async function executeToolWithPermissions(
111
112
 
112
113
  const matchedRule = matchPermissionRule(options.getPermissionRules(), request)
113
114
  const decision: PermissionDecision =
114
- modePolicy(options.permissionMode).autoAllowToolKind(request.kind)
115
+ modePolicy(options.permissionMode).autoAllowToolKind(tool.kind)
115
116
  ? 'allow-once'
116
117
  : matchedRule
117
118
  ? 'allow-once'
@@ -10,6 +10,7 @@ import {
10
10
  type ProviderTurnEvent =
11
11
  | { type: 'text'; delta: string }
12
12
  | { type: 'thinking'; delta: string }
13
+ | { type: 'thinking_end' }
13
14
  | ProviderRetryStreamEvent
14
15
  | { type: 'tool_use_start'; id: string; name: string }
15
16
  | { type: 'tool_use_delta'; id: string; delta: string }
@@ -46,6 +47,7 @@ function normalize(event: StreamEvent): ProviderTurnEvent {
46
47
  switch (event.type) {
47
48
  case 'text': return { type: 'text', delta: event.delta }
48
49
  case 'thinking': return { type: 'thinking', delta: event.delta }
50
+ case 'thinking_end': return { type: 'thinking_end' }
49
51
  case 'retry': return event
50
52
  case 'tool_use_start': return event
51
53
  case 'tool_use_delta': return event
@@ -67,6 +69,7 @@ export type ContinuationNudgeReason =
67
69
  | 'tool_budget'
68
70
  | 'private_continuity_tool'
69
71
  | 'private_continuity_tool_repair'
72
+ | 'write_file_repair'
70
73
  | 'reasoning_only'
71
74
 
72
75
  const CONTINUATION_NUDGE_TEXT =
@@ -93,6 +96,9 @@ const PRIVATE_CONTINUITY_NUDGE_TEXT =
93
96
  const PRIVATE_CONTINUITY_REPAIR_NUDGE_TEXT =
94
97
  'The previous propose_private_continuity_edit call had invalid or missing input. Retry the same native tool now with complete arguments. Do not answer in prose and do not search for markdown files. For memory/preferences use {"file":"MEMORY.md","appendToSection":"Durable User Preferences","appendText":"- User preference or memory note."}. For persona use {"file":"SOUL.md","appendToSection":"Persona","appendText":"- Persona or standing behavior note."}.'
95
98
 
99
+ const WRITE_FILE_REPAIR_NUDGE_TEXT =
100
+ 'The previous write_file call was rejected because the arguments were missing or malformed. Retry the same native tool now with a JSON object (not a JSON string) shaped exactly like {"path":"relative/path.ext","content":"...complete file contents..."}. Both fields are required and must be non-empty. Do not answer in prose.'
101
+
96
102
  const REASONING_ONLY_NUDGE_TEXT =
97
103
  'You produced private reasoning but no user-visible answer. Answer the user now in visible text. Do not continue only with reasoning.'
98
104
 
@@ -100,6 +106,7 @@ export type TurnEvent =
100
106
  | { type: 'iteration_start'; index: number }
101
107
  | { type: 'text'; delta: string }
102
108
  | { type: 'thinking'; delta: string }
109
+ | { type: 'thinking_end' }
103
110
  | ProviderRetryStreamEvent
104
111
  | { type: 'tool_use_start'; id: string; name: string }
105
112
  | { type: 'tool_use_delta'; id: string; delta: string }
@@ -203,6 +210,8 @@ export async function* runRuntimeTurn(
203
210
  } else if (ev.type === 'thinking') {
204
211
  thinkingSeen = true
205
212
  yield { type: 'thinking', delta: ev.delta }
213
+ } else if (ev.type === 'thinking_end') {
214
+ yield { type: 'thinking_end' }
206
215
  } else if (ev.type === 'tool_use_start') {
207
216
  yield { type: 'tool_use_start', id: ev.id, name: ev.name }
208
217
  } else if (ev.type === 'tool_use_delta') {
@@ -279,7 +288,7 @@ export async function* runRuntimeTurn(
279
288
  }
280
289
  yield {
281
290
  type: 'error',
282
- message: 'model printed tool names instead of making a tool call',
291
+ message: 'Model printed tool names instead of making a tool call',
283
292
  discardAssistant: true,
284
293
  }
285
294
  yield doneEvent(false, stopReason)
@@ -302,7 +311,7 @@ export async function* runRuntimeTurn(
302
311
  }
303
312
  yield {
304
313
  type: 'error',
305
- message: 'model asked the user to run a tool instead of making a tool call',
314
+ message: 'Model asked the user to run a tool instead of making a tool call',
306
315
  discardAssistant: true,
307
316
  }
308
317
  yield doneEvent(false, stopReason)
@@ -333,7 +342,7 @@ export async function* runRuntimeTurn(
333
342
  }
334
343
  yield {
335
344
  type: 'error',
336
- message: 'model claimed workspace state without matching tool evidence',
345
+ message: 'Model claimed workspace state without matching tool evidence',
337
346
  discardAssistant: true,
338
347
  }
339
348
  yield doneEvent(false, stopReason)
@@ -342,26 +351,18 @@ export async function* runRuntimeTurn(
342
351
  }
343
352
 
344
353
  if (pendingToolUses.length === 0) {
345
- if (!assistantText && thinkingSeen) {
346
- if (continuationNudges < maxContinuationNudges) {
347
- continuationNudges += 1
348
- yield {
349
- type: 'continuation_nudge',
350
- attempt: continuationNudges,
351
- reason: 'reasoning_only',
352
- }
353
- workingMessages = [
354
- ...await rebuildMessages(),
355
- { role: 'user', content: REASONING_ONLY_NUDGE_TEXT },
356
- ]
357
- continue
358
- }
354
+ if (!assistantText && thinkingSeen && continuationNudges < maxContinuationNudges) {
355
+ continuationNudges += 1
359
356
  yield {
360
- type: 'error',
361
- message: 'model produced reasoning but no visible answer',
357
+ type: 'continuation_nudge',
358
+ attempt: continuationNudges,
359
+ reason: 'reasoning_only',
362
360
  }
363
- yield doneEvent(false, stopReason)
364
- return
361
+ workingMessages = [
362
+ ...await rebuildMessages(),
363
+ { role: 'user', content: REASONING_ONLY_NUDGE_TEXT },
364
+ ]
365
+ continue
365
366
  }
366
367
 
367
368
  const nudge = nextNudge(provider, assistantText)
@@ -387,7 +388,7 @@ export async function* runRuntimeTurn(
387
388
  if (assistantText && nudge?.reason === 'tool_capability') {
388
389
  yield {
389
390
  type: 'error',
390
- message: 'model refused available tools after corrective nudges',
391
+ message: 'Model refused available tools after corrective nudges',
391
392
  }
392
393
  yield doneEvent(false, stopReason)
393
394
  return
@@ -456,17 +457,17 @@ export async function* runRuntimeTurn(
456
457
  yield {
457
458
  type: 'continuation_nudge',
458
459
  attempt: continuationNudges,
459
- reason: 'private_continuity_tool_repair',
460
+ reason: repairNudge.reason,
460
461
  }
461
462
  workingMessages = [
462
463
  ...await rebuildMessages(),
463
- { role: 'user', content: repairNudge },
464
+ { role: 'user', content: repairNudge.text },
464
465
  ]
465
466
  continue
466
467
  }
467
468
  yield {
468
469
  type: 'error',
469
- message: 'model called propose_private_continuity_edit with invalid input after corrective nudges',
470
+ message: repairNudge.failureMessage,
470
471
  discardAssistant: true,
471
472
  }
472
473
  yield doneEvent(false, stopReason)
@@ -485,26 +486,56 @@ function doneEvent(finishedNormally: boolean, stopReason?: TurnStopReason): Extr
485
486
  return { type: 'done', finishedNormally }
486
487
  }
487
488
 
489
+ type RepairNudge = {
490
+ text: string
491
+ reason: ContinuationNudgeReason
492
+ failureMessage: string
493
+ }
494
+
488
495
  function nextToolResultRepairNudge(
489
496
  provider: Pick<Provider, 'id' | 'supportsTools'>,
490
497
  completedTools: ExecutedToolUse[],
491
- ): string | null {
498
+ ): RepairNudge | null {
492
499
  if (!provider.supportsTools) return null
493
500
  const failedPrivateEdit = completedTools.some(completed =>
494
501
  completed.name === 'propose_private_continuity_edit'
495
502
  && !completed.result.ok
496
503
  && completed.result.summary === 'propose_private_continuity_edit rejected input',
497
504
  )
498
- if (failedPrivateEdit) return PRIVATE_CONTINUITY_REPAIR_NUDGE_TEXT
505
+ if (failedPrivateEdit) {
506
+ return {
507
+ text: PRIVATE_CONTINUITY_REPAIR_NUDGE_TEXT,
508
+ reason: 'private_continuity_tool_repair',
509
+ failureMessage: 'Model called propose_private_continuity_edit with invalid input after corrective nudges',
510
+ }
511
+ }
512
+
513
+ const failedWriteFile = completedTools.some(completed =>
514
+ completed.name === 'write_file'
515
+ && !completed.result.ok
516
+ && completed.result.summary === 'write_file rejected input',
517
+ )
518
+ if (failedWriteFile) {
519
+ return {
520
+ text: WRITE_FILE_REPAIR_NUDGE_TEXT,
521
+ reason: 'write_file_repair',
522
+ failureMessage: 'Model called write_file with invalid input after corrective nudges',
523
+ }
524
+ }
499
525
 
500
526
  const failedWorkspacePrivateRead = completedTools.some(completed =>
501
527
  completed.name === 'read_file'
502
528
  && !completed.result.ok
503
529
  && /read_private_continuity_file/.test(completed.result.content),
504
530
  )
505
- return failedWorkspacePrivateRead
506
- ? 'The previous read_file call targeted private identity continuity markdown. Retry now with read_private_continuity_file and complete input such as {"file":"MEMORY.md"} or {"file":"SOUL.md"}. Do not search workspace folders.'
507
- : null
531
+ if (failedWorkspacePrivateRead) {
532
+ return {
533
+ text: 'The previous read_file call targeted private identity continuity markdown. Retry now with read_private_continuity_file and complete input such as {"file":"MEMORY.md"} or {"file":"SOUL.md"}. Do not search workspace folders.',
534
+ reason: 'private_continuity_tool_repair',
535
+ failureMessage: 'Model kept reading private continuity files via read_file after corrective nudges',
536
+ }
537
+ }
538
+ return null
508
539
  }
509
540
 
510
541
  export function parseLocalModelTextToolUse(
@@ -32,7 +32,7 @@ export const changeDirectoryTool: Tool<typeof schema> = {
32
32
  path: fullPath,
33
33
  relativePath: path.relative(context.workspaceRoot, fullPath) || path.basename(fullPath),
34
34
  directoryPath: path.dirname(fullPath),
35
- title: 'allow directory change?',
35
+ title: 'Allow directory change?',
36
36
  subtitle: fullPath,
37
37
  }
38
38
  },
@@ -48,6 +48,16 @@ export type PermissionRequest =
48
48
  file: 'SOUL.md' | 'MEMORY.md'
49
49
  range: string
50
50
  }
51
+ | {
52
+ kind: 'private-skill-read'
53
+ path: string
54
+ relativePath: string
55
+ directoryPath: string
56
+ title: string
57
+ subtitle: string
58
+ skillName: string
59
+ mode: 'list' | 'read'
60
+ }
51
61
  | {
52
62
  kind: 'private-continuity-edit'
53
63
  path: string
@@ -32,7 +32,7 @@ export const deleteFileTool: Tool<typeof schema> = {
32
32
  path: prepared.fullPath,
33
33
  relativePath: prepared.relativePath,
34
34
  directoryPath: path.dirname(prepared.fullPath),
35
- title: 'allow file delete?',
35
+ title: 'Allow file delete?',
36
36
  subtitle: prepared.fullPath,
37
37
  before: preview(prepared.before),
38
38
  after: '(deleted)',
@@ -41,7 +41,7 @@ export const editTool: Tool<typeof schema> = {
41
41
  path: fullPath,
42
42
  relativePath,
43
43
  directoryPath: path.dirname(fullPath),
44
- title: 'allow file edit?',
44
+ title: 'Allow file edit?',
45
45
  subtitle: fullPath,
46
46
  before: applied.previewBefore,
47
47
  after: applied.previewAfter,
@@ -31,7 +31,7 @@ export const listDirectoryTool: Tool<typeof schema> = {
31
31
  path: fullPath,
32
32
  relativePath,
33
33
  directoryPath: fullPath,
34
- title: 'allow directory listing?',
34
+ title: 'Allow directory listing?',
35
35
  subtitle: fullPath,
36
36
  }
37
37
  },
@@ -0,0 +1,77 @@
1
+ import { z } from 'zod'
2
+ import path from 'node:path'
3
+ import {
4
+ continuityVaultRef,
5
+ } from '../identity/continuity/storage.js'
6
+ import { listSkillFiles } from '../identity/continuity/skills/loadSkills.js'
7
+ import type { Tool } from './contracts.js'
8
+
9
+ const schema = z.object({
10
+ name: z.string().min(1),
11
+ })
12
+
13
+ export const listSkillFilesTool: Tool<typeof schema> = {
14
+ name: 'list_private_skill_files',
15
+ kind: 'private-continuity-read',
16
+ readOnly: true,
17
+ description: [
18
+ 'List every file inside a private skill folder for the active identity.',
19
+ 'Returns each file as `- <relativePath> (<bytes>) — <absolutePath>`, including SKILL.md and any supporting files (references, examples, scripts).',
20
+ 'Use this after list_private_skills shows a skill with `(+N supporting files)` to discover what is available, then call read_private_skill with `file` to load text content.',
21
+ 'Pass the absolute path directly to run_bash to execute supporting scripts (e.g. `python "<absolutePath>" <args>`).',
22
+ ].join(' '),
23
+ inputSchema: schema,
24
+ inputSchemaJson: {
25
+ type: 'object',
26
+ properties: {
27
+ name: { type: 'string', description: 'Skill folder name from the private skills index.' },
28
+ },
29
+ required: ['name'],
30
+ additionalProperties: false,
31
+ },
32
+ parse(input) {
33
+ return schema.parse(input)
34
+ },
35
+ async buildPermissionRequest(input, context) {
36
+ const identity = context.config?.identity
37
+ if (!identity) throw new Error('No active identity; create or load an identity before listing private skill files')
38
+ const ref = continuityVaultRef(identity)
39
+ const folder = input.name.replace(/^.*:/, '')
40
+ const skillDir = path.join(ref.skillsDir, folder)
41
+ return {
42
+ kind: 'private-skill-read',
43
+ path: skillDir,
44
+ relativePath: `identity-vault/skills/${folder}`,
45
+ directoryPath: skillDir,
46
+ title: 'Allow private skill folder list?',
47
+ subtitle: `List files in ${folder}/`,
48
+ skillName: input.name,
49
+ mode: 'list',
50
+ }
51
+ },
52
+ async execute(input, context) {
53
+ const identity = context.config?.identity
54
+ if (!identity) {
55
+ return { ok: false, summary: 'no active identity', content: 'No active identity; cannot list private skill files.' }
56
+ }
57
+ try {
58
+ const folder = input.name.replace(/^.*:/, '')
59
+ const files = await listSkillFiles(identity, folder)
60
+ if (files.length === 0) {
61
+ return { ok: true, summary: `no files in ${folder}`, content: `Skill folder ${folder}/ is empty or does not exist.` }
62
+ }
63
+ const lines = files.map(f => `- ${f.relativePath} (${f.sizeBytes} bytes) — ${f.absolutePath}`)
64
+ return {
65
+ ok: true,
66
+ summary: `listed ${files.length} file${files.length === 1 ? '' : 's'} in ${folder}`,
67
+ content: lines.join('\n'),
68
+ }
69
+ } catch (err: unknown) {
70
+ return {
71
+ ok: false,
72
+ summary: 'list failed',
73
+ content: (err as Error).message,
74
+ }
75
+ }
76
+ },
77
+ }