mixdog 0.9.23 → 0.9.25
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.
- package/package.json +2 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/channel-daemon-smoke.mjs +327 -9
- package/scripts/channel-daemon-stub.mjs +12 -1
- package/scripts/debounced-skills-async-save-test.mjs +57 -0
- package/scripts/explore-bench-tmp.mjs +17 -0
- package/scripts/find-fuzzy-hidden-test.mjs +145 -0
- package/scripts/mcp-grace-deferred-test.mjs +149 -0
- package/scripts/tool-smoke.mjs +38 -30
- package/src/defaults/cycle3-review-prompt.md +11 -4
- package/src/defaults/memory-promote-prompt.md +9 -0
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
- package/src/runtime/agent/orchestrator/config.mjs +33 -7
- package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
- package/src/runtime/channels/backends/discord.mjs +10 -3
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
- package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
- package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +9 -28
- package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -1
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
- package/src/runtime/memory/lib/query-handlers.mjs +4 -1
- package/src/runtime/memory/lib/recall-format.mjs +7 -3
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +130 -2
- package/src/runtime/shared/background-tasks.mjs +1 -1
- package/src/runtime/shared/config.mjs +53 -1
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +19 -0
- package/src/runtime/shared/update-checker.mjs +3 -0
- package/src/runtime/shared/user-data-guard.mjs +66 -0
- package/src/session-runtime/config-lifecycle.mjs +175 -15
- package/src/session-runtime/mcp-glue.mjs +30 -0
- package/src/session-runtime/runtime-core.mjs +91 -7
- package/src/session-runtime/session-turn-api.mjs +42 -16
- package/src/session-runtime/tool-catalog.mjs +44 -0
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +3 -1
- package/src/standalone/channel-daemon-transport.mjs +202 -8
- package/src/standalone/channel-daemon.mjs +54 -17
- package/src/standalone/channel-worker.mjs +18 -7
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/tui/App.jsx +2 -2
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +14 -2
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +246 -47
- package/src/tui/engine/agent-job-feed.mjs +47 -3
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +6 -1
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +31 -12
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +15 -5
- package/src/workflows/default/WORKFLOW.md +29 -38
- package/src/workflows/solo/WORKFLOW.md +15 -20
- package/src/runtime/channels/lib/seat-lock.mjs +0 -196
|
@@ -11,6 +11,65 @@ const LLM_JUDGE_CAP = 20
|
|
|
11
11
|
|
|
12
12
|
const TRANSIENT_PROMOTE_CATEGORIES = new Set(['task', 'issue'])
|
|
13
13
|
|
|
14
|
+
// Category grades the pipeline trusts outright: user/cycle1 tagged durable
|
|
15
|
+
// knowledge. These are NEVER content-scanned — a curated constraint like
|
|
16
|
+
// "95% pass threshold" or a rule that cites a metric must still promote.
|
|
17
|
+
const DURABLE_TRUSTED_CATEGORIES = new Set(['rule', 'constraint', 'decision', 'preference', 'goal'])
|
|
18
|
+
|
|
19
|
+
// Deterministic (non-LLM) signature for transient work-state: status/review
|
|
20
|
+
// churn and benchmark/measurement RESULT snapshots. Narrow on purpose — it
|
|
21
|
+
// requires snapshot phrasing (a measurement verb or an explicit result-metric
|
|
22
|
+
// noun), NOT a bare percentage, so SLO/threshold constraints don't trip it.
|
|
23
|
+
// Applied only to non-durable categories (e.g. 'fact'), catching benchmark
|
|
24
|
+
// snapshots mis-tagged as durable-ish. The gate prompt only *discourages*
|
|
25
|
+
// these; this is the structural enforcement.
|
|
26
|
+
const TRANSIENT_SNAPSHOT_RE = new RegExp(
|
|
27
|
+
[
|
|
28
|
+
'terminal-bench',
|
|
29
|
+
'\\bbenchmark(ed|ing|\\s+(run|result|score))\\b',
|
|
30
|
+
'pass@\\d',
|
|
31
|
+
'\\btokens?\\s?\\/\\s?s(ec)?\\b',
|
|
32
|
+
'status\\s+snapshot',
|
|
33
|
+
'review(er)?\\s+(validation\\s+)?cycles?',
|
|
34
|
+
'in[-\\s]progress',
|
|
35
|
+
'\\bWIP\\b',
|
|
36
|
+
'\\b(scored|achieved|measured|reached)\\b[^.]{0,40}\\d+(\\.\\d+)?\\s?%',
|
|
37
|
+
'\\d+(\\.\\d+)?\\s?%\\s+(pass\\s+rate|accuracy|score|throughput|latency)',
|
|
38
|
+
].join('|'),
|
|
39
|
+
'i',
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// Structural promotion gate: is this row transient content (task/issue chatter
|
|
43
|
+
// or a status/benchmark snapshot) that must not be promoted pending→active?
|
|
44
|
+
// task/issue block by category. Durable knowledge categories are trusted and
|
|
45
|
+
// skip the content scan. Everything else (e.g. 'fact') is content-scanned so
|
|
46
|
+
// snapshots mis-categorized as durable are still blocked.
|
|
47
|
+
export function isTransientPromotion(row) {
|
|
48
|
+
if (!row) return false
|
|
49
|
+
const cat = String(row.category ?? '').toLowerCase()
|
|
50
|
+
if (TRANSIENT_PROMOTE_CATEGORIES.has(cat)) return true
|
|
51
|
+
if (DURABLE_TRUSTED_CATEGORIES.has(cat)) return false
|
|
52
|
+
return TRANSIENT_SNAPSHOT_RE.test(`${row.element ?? ''} ${row.summary ?? ''}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Strip pending→active promotions for transient content out of the status
|
|
56
|
+
// batch BEFORE the cap clamp. Blocked rows are simply dropped from the batch,
|
|
57
|
+
// so they stay pending (held for re-confirmation on a later cycle) — never
|
|
58
|
+
// archived here, and reviewed_at is still bumped via the caller's reviewedIds.
|
|
59
|
+
export function blockTransientPromotions(statusBatch, rowsById) {
|
|
60
|
+
if (!statusBatch?.length) return { batch: statusBatch ?? [], blocked: 0 }
|
|
61
|
+
let blocked = 0
|
|
62
|
+
const batch = statusBatch.filter((item) => {
|
|
63
|
+
if (item.was_pending && item.new_status === 'active'
|
|
64
|
+
&& isTransientPromotion(rowsById.get(Number(item.entry_id)))) {
|
|
65
|
+
blocked += 1
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
return true
|
|
69
|
+
})
|
|
70
|
+
return { batch, blocked }
|
|
71
|
+
}
|
|
72
|
+
|
|
14
73
|
// After the gate, cap how many pending→active promotions may land in one batch.
|
|
15
74
|
// Overflow stays pending (not archived). Tiebreak: durable category before
|
|
16
75
|
// task/issue, then score DESC, then older last_seen_at, then id ASC.
|
|
@@ -11,7 +11,7 @@ import { backfillCoreEmbeddings, nominateCoreCandidates, CORE_SUMMARY_MAX } from
|
|
|
11
11
|
import { markCycleRequest, consumeCycleRequests, resolveCoalesceMaxDrains, scheduleCoalescedCycleRetry, makeCycleRequestSignature, resolveCoalesceMaxRetries } from './memory-cycle-requests.mjs'
|
|
12
12
|
import { __mixdogMemoryLog, throwIfAborted } from './memory-cycle2-shared.mjs'
|
|
13
13
|
import {
|
|
14
|
-
applyBatchStatusVerdicts, clampPendingPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
|
|
14
|
+
applyBatchStatusVerdicts, clampPendingPromotions, blockTransientPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
|
|
15
15
|
} from './memory-cycle2-mutations.mjs'
|
|
16
16
|
import {
|
|
17
17
|
CYCLE2_ACTIVE_TARGET_CAP, CYCLE2_ACTIVE_MIN_FLOOR, loadCurrentRulesDigest, runUnifiedGate, sonnetCascade,
|
|
@@ -54,6 +54,7 @@ function mergeCycle2Results(a, b) {
|
|
|
54
54
|
rejected_verb: Number(a.rejected_verb || 0) + Number(b.rejected_verb || 0),
|
|
55
55
|
merge_rejected: Number(a.merge_rejected || 0) + Number(b.merge_rejected || 0),
|
|
56
56
|
missing_core_summary: Number(a.missing_core_summary || 0) + Number(b.missing_core_summary || 0),
|
|
57
|
+
promotion_blocked: Number(a.promotion_blocked || 0) + Number(b.promotion_blocked || 0),
|
|
57
58
|
core_embedding_backfill: Number(a.core_embedding_backfill || 0) + Number(b.core_embedding_backfill || 0),
|
|
58
59
|
core_candidates_nominated: Number(a.core_candidates_nominated || 0) + Number(b.core_candidates_nominated || 0),
|
|
59
60
|
rescore: mergeNestedNumeric(a.rescore, b.rescore),
|
|
@@ -211,6 +212,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
|
|
|
211
212
|
updated: 0, kept: 0, rejected_verb: 0,
|
|
212
213
|
merge_rejected: 0,
|
|
213
214
|
missing_core_summary: 0,
|
|
215
|
+
promotion_blocked: 0,
|
|
214
216
|
core_embedding_backfill: 0,
|
|
215
217
|
core_candidates_nominated: 0,
|
|
216
218
|
rescore: { updated: 0 },
|
|
@@ -523,8 +525,13 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
|
|
|
523
525
|
}
|
|
524
526
|
guardedBatch.push(item)
|
|
525
527
|
}
|
|
528
|
+
// Structural transient block runs BEFORE the cap clamp: task/issue chatter
|
|
529
|
+
// and status/benchmark snapshots can never promote pending→active, so they
|
|
530
|
+
// are stripped here (held pending) regardless of remaining cap slots.
|
|
531
|
+
const blockRes = blockTransientPromotions(guardedBatch, rowsById)
|
|
532
|
+
stats.promotion_blocked = blockRes.blocked
|
|
526
533
|
const activeCountForClamp = Math.max(0, activeCount - reservedDemotions)
|
|
527
|
-
const clampRes = clampPendingPromotions(
|
|
534
|
+
const clampRes = clampPendingPromotions(blockRes.batch, rowsById, activeCountForClamp, activeTargetCap)
|
|
528
535
|
stats.promotion_clamped = clampRes.clamped
|
|
529
536
|
const batchRes = await applyBatchStatusVerdicts(db, clampRes.batch, nowMs)
|
|
530
537
|
stats.promoted += batchRes.promoted
|
|
@@ -702,7 +709,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
|
|
|
702
709
|
` core_backfill=${stats.core_embedding_backfill}` +
|
|
703
710
|
` active=${activeCount}/${activeTargetCap} review_active=${reviewActiveRows ? 1 : 0}` +
|
|
704
711
|
` | gate promoted=${stats.promoted} archived=${stats.archived}` +
|
|
705
|
-
` promotion_clamped=${stats.promotion_clamped}` +
|
|
712
|
+
` promotion_clamped=${stats.promotion_clamped} promotion_blocked=${stats.promotion_blocked}` +
|
|
706
713
|
` updated=${stats.updated} kept=${stats.kept}` +
|
|
707
714
|
` rejected_verb=${stats.rejected_verb} merge_rejected=${stats.merge_rejected}` +
|
|
708
715
|
` missing_core=${stats.missing_core_summary}` +
|
|
@@ -11,13 +11,17 @@ function __mixdogMemoryLog(...args) {
|
|
|
11
11
|
// {{CORE_REVIEW}} block for defaults/cycle3-review-prompt.md, then asks the
|
|
12
12
|
// maintenance-preset LLM for one verdict per id. By default Cycle3 performs
|
|
13
13
|
// conservative cleanup: safe compression updates and strict duplicate merges
|
|
14
|
-
// are applied
|
|
14
|
+
// are applied. Deletes now also apply in conservative mode when the model
|
|
15
|
+
// tags the entry as clear junk (a whitelisted junk reason) and — for
|
|
16
|
+
// redundant-with-default reasons — the text actually echoes a current rule,
|
|
17
|
+
// bounded by a per-run delete cap so a run keeps a safety margin instead of
|
|
18
|
+
// nuking the whole set. Unreasoned / non-junk deletes still require APPLY.
|
|
15
19
|
//
|
|
16
20
|
// Verdict line grammar (mirrors parseUnifiedFormat in memory-cycle2.mjs):
|
|
17
21
|
// <id>|keep
|
|
18
22
|
// <id>|update|<element>|<summary>
|
|
19
23
|
// <id>|merge|<target_id>|<source_ids_csv>
|
|
20
|
-
// <id>|delete
|
|
24
|
+
// <id>|delete|<reason>
|
|
21
25
|
|
|
22
26
|
import { existsSync, readFileSync } from 'fs'
|
|
23
27
|
import { join } from 'path'
|
|
@@ -136,6 +140,58 @@ function isStrictDuplicate(a, b) {
|
|
|
136
140
|
return sim >= 0.78
|
|
137
141
|
}
|
|
138
142
|
|
|
143
|
+
// Whitelisted delete reasons that conservative mode may auto-apply. These are
|
|
144
|
+
// the "clear junk" classes: a copy of a built-in/default rule, a bare
|
|
145
|
+
// restatement, an obsolete/already-implemented decision, or a past-event log.
|
|
146
|
+
// Anything outside this set (or a bare `delete` with no reason) stays held for
|
|
147
|
+
// APPLY CYCLE3 so genuinely durable rules are never removed unattended.
|
|
148
|
+
const SAFE_DELETE_REASONS = new Set([
|
|
149
|
+
'duplicate', 'dup', 'duplicate_of_default', 'default', 'redundant',
|
|
150
|
+
'restatement', 'restate', 'restates_default',
|
|
151
|
+
'obsolete', 'implemented', 'done', 'completed', 'resolved',
|
|
152
|
+
'superseded_decision', 'stale', 'past_event', 'event_log', 'log',
|
|
153
|
+
])
|
|
154
|
+
// Reasons that claim redundancy with a built-in/default rule — these demand
|
|
155
|
+
// corroboration (the core text must actually echo the current rules digest)
|
|
156
|
+
// before a conservative auto-delete, so a mislabelled durable rule survives.
|
|
157
|
+
const DEFAULT_ECHO_REASONS = new Set([
|
|
158
|
+
'duplicate', 'dup', 'duplicate_of_default', 'default', 'redundant',
|
|
159
|
+
'restatement', 'restate', 'restates_default',
|
|
160
|
+
])
|
|
161
|
+
|
|
162
|
+
function normalizeDeleteReason(reason) {
|
|
163
|
+
return String(reason ?? '')
|
|
164
|
+
.toLowerCase()
|
|
165
|
+
.trim()
|
|
166
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
167
|
+
.replace(/^_+|_+$/g, '')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Max trigram similarity of `text` against any substantive line of the current
|
|
171
|
+
// rules digest — how strongly a core entry echoes a built-in rule.
|
|
172
|
+
function digestRedundancy(text, rulesDigest) {
|
|
173
|
+
if (!text || !rulesDigest) return 0
|
|
174
|
+
const lines = String(rulesDigest).split('\n').map(l => l.trim()).filter(l => l.length >= 12)
|
|
175
|
+
let best = 0
|
|
176
|
+
for (const line of lines) {
|
|
177
|
+
const d = charDice(text, line)
|
|
178
|
+
if (d > best) best = d
|
|
179
|
+
if (best >= 0.9) break
|
|
180
|
+
}
|
|
181
|
+
return best
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isSafeConservativeDelete(core, action, rulesDigest) {
|
|
185
|
+
const reason = normalizeDeleteReason(action?.reason)
|
|
186
|
+
if (!reason) return { ok: false, reason: 'delete needs a junk reason → APPLY CYCLE3' }
|
|
187
|
+
if (!SAFE_DELETE_REASONS.has(reason)) return { ok: false, reason: `delete reason "${reason}" not in safe set → APPLY CYCLE3` }
|
|
188
|
+
if (DEFAULT_ECHO_REASONS.has(reason)) {
|
|
189
|
+
const red = digestRedundancy(coreText(core), rulesDigest)
|
|
190
|
+
if (red < 0.5) return { ok: false, reason: `not redundant with defaults (sim=${red.toFixed(2)})` }
|
|
191
|
+
}
|
|
192
|
+
return { ok: true, reason }
|
|
193
|
+
}
|
|
194
|
+
|
|
139
195
|
function formatRelatedRow(r) {
|
|
140
196
|
const tag = r.project_id ? r.project_id : 'COMMON'
|
|
141
197
|
const stat = r.status ? `[${r.status}]` : '[?]'
|
|
@@ -206,7 +262,8 @@ function parseVerdicts(raw, idSet) {
|
|
|
206
262
|
}
|
|
207
263
|
actions.push({ id, verb: 'merge', targetId, sourceIds })
|
|
208
264
|
} else if (verb === 'delete') {
|
|
209
|
-
|
|
265
|
+
const reason = (parts[2] ?? '').trim()
|
|
266
|
+
actions.push({ id, verb: 'delete', reason: reason || null })
|
|
210
267
|
} else if (verb === 'superseded' || verb === 'supersede') {
|
|
211
268
|
// Require newer-id proof: `id|superseded|<newer_id>`. Without a valid
|
|
212
269
|
// newer active core id the supersession has no evidence → drop to keep.
|
|
@@ -553,6 +610,10 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
|
|
|
553
610
|
const held = { updated: 0, merged: 0, deleted: 0, superseded: 0 }
|
|
554
611
|
const details = []
|
|
555
612
|
const touched = new Set() // ids already acted on this cycle — avoid double action
|
|
613
|
+
// Safety margin: even when every junk verdict is legitimate, a single
|
|
614
|
+
// conservative run applies at most this many outright deletes and holds the
|
|
615
|
+
// rest for the next pass. Prevents an over-eager model from clearing the set.
|
|
616
|
+
const conservativeDeleteCap = Math.max(1, Math.ceil(cores.length * 0.35))
|
|
556
617
|
|
|
557
618
|
// Core-store edit/delete calls are the mutation unit; checkpoints sit before
|
|
558
619
|
// each action/source and after each awaited unit, not inside one file-store write.
|
|
@@ -596,19 +657,28 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
|
|
|
596
657
|
}
|
|
597
658
|
if (a.verb === 'delete') {
|
|
598
659
|
proposed.deleted++
|
|
599
|
-
|
|
660
|
+
// confirmed → always delete. conservative → delete only clear junk (safe
|
|
661
|
+
// reason, corroborated for redundant-with-default), capped per run.
|
|
662
|
+
// proposal → always hold.
|
|
663
|
+
let applyDelete = confirmed
|
|
664
|
+
let holdReason = 'proposal mode'
|
|
665
|
+
let safeReason = null
|
|
666
|
+
if (!confirmed && conservative) {
|
|
667
|
+
const safeDel = isSafeConservativeDelete(coreById.get(a.id), a, rulesDigest)
|
|
668
|
+
if (!safeDel.ok) holdReason = safeDel.reason
|
|
669
|
+
else if (deleted >= conservativeDeleteCap) holdReason = `conservative delete cap ${conservativeDeleteCap} reached`
|
|
670
|
+
else { applyDelete = true; safeReason = safeDel.reason }
|
|
671
|
+
}
|
|
672
|
+
if (!applyDelete) {
|
|
600
673
|
held.deleted++
|
|
601
|
-
details.push({
|
|
602
|
-
id: a.id, verb: 'delete', applied: false, held: true,
|
|
603
|
-
reason: conservative ? 'delete requires APPLY CYCLE3' : 'proposal mode',
|
|
604
|
-
})
|
|
674
|
+
details.push({ id: a.id, verb: 'delete', applied: false, held: true, reason: holdReason })
|
|
605
675
|
touched.add(a.id)
|
|
606
676
|
continue
|
|
607
677
|
}
|
|
608
678
|
try {
|
|
609
679
|
await deleteCore(dataDir, a.id)
|
|
610
680
|
deleted++
|
|
611
|
-
details.push({ id: a.id, verb: 'delete', applied: true })
|
|
681
|
+
details.push({ id: a.id, verb: 'delete', applied: true, reason: safeReason || 'confirmed' })
|
|
612
682
|
touched.add(a.id)
|
|
613
683
|
} catch (err) {
|
|
614
684
|
if (signal?.aborted) throw signal.reason ?? err
|
|
@@ -700,7 +700,10 @@ export function createQueryHandlers({
|
|
|
700
700
|
// caller's own session marked "(current)" via the currentSessionId hint.
|
|
701
701
|
// Falls through to the flat list when everything is one session.
|
|
702
702
|
const _currentSessionHint = String(args?.currentSessionId || '').trim()
|
|
703
|
-
|
|
703
|
+
// recencyOrder on the date path: without it, chunk members (stored ts-ASC
|
|
704
|
+
// per root) interleave out of order with raw rows inside each session
|
|
705
|
+
// group (e.g. 04:33 rendered above 04:41).
|
|
706
|
+
return { text: recallCapPrefix + renderSessionGroupedLines(sliced, { currentSessionId: _currentSessionHint, recencyOrder: sort === 'date' }) }
|
|
704
707
|
}
|
|
705
708
|
|
|
706
709
|
async function dumpSessionRootChunks(args = {}) {
|
|
@@ -316,7 +316,11 @@ function shortSessionLabel(sid) {
|
|
|
316
316
|
// The caller's own session (currentSessionId hint) is marked "(current)".
|
|
317
317
|
// Single-session (or session-less) result sets fall through to the flat
|
|
318
318
|
// renderer — no headers when grouping adds nothing.
|
|
319
|
-
|
|
319
|
+
// recencyOrder (date-sorted browse) is forwarded to renderEntryLines so each
|
|
320
|
+
// group's lines are globally ts-desc: without it a chunk root's members (stored
|
|
321
|
+
// ts-ASC) interleave with raw rows and invert the visible timeline within a
|
|
322
|
+
// session (e.g. 04:33 rendered above 04:41).
|
|
323
|
+
export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder = false } = {}) {
|
|
320
324
|
if (!rows || rows.length === 0) return '(no results)'
|
|
321
325
|
const groups = new Map()
|
|
322
326
|
for (const r of rows) {
|
|
@@ -325,14 +329,14 @@ export function renderSessionGroupedLines(rows, { currentSessionId } = {}) {
|
|
|
325
329
|
if (!groups.has(key)) groups.set(key, [])
|
|
326
330
|
groups.get(key).push(r)
|
|
327
331
|
}
|
|
328
|
-
if (groups.size <= 1) return renderEntryLines(rows)
|
|
332
|
+
if (groups.size <= 1) return renderEntryLines(rows, { recencyOrder })
|
|
329
333
|
const current = String(currentSessionId || '').trim()
|
|
330
334
|
const parts = []
|
|
331
335
|
for (const [sid, groupRows] of groups) {
|
|
332
336
|
const mark = current && sid === current ? ' (current)' : ''
|
|
333
337
|
const label = sid === '(no session)' ? sid : `session ${shortSessionLabel(sid)}`
|
|
334
338
|
parts.push(`## ${label}${mark}`)
|
|
335
|
-
parts.push(renderEntryLines(groupRows))
|
|
339
|
+
parts.push(renderEntryLines(groupRows, { recencyOrder }))
|
|
336
340
|
}
|
|
337
341
|
return parts.join('\n')
|
|
338
342
|
}
|
|
@@ -21,7 +21,7 @@ export const TOOL_DEFS = [
|
|
|
21
21
|
status: { type: 'string', enum: ['pending','active','archived'], description: 'Lifecycle status.' },
|
|
22
22
|
limit: { type: 'number', description: 'Max rows/items.' },
|
|
23
23
|
confirm: { type: 'string', description: 'Exact confirmation phrase for destructive actions.' },
|
|
24
|
-
project_id: { type: 'string', description: 'Core pool: common, slug, or *.' },
|
|
24
|
+
project_id: { type: 'string', description: 'Core pool: common, slug, or *. Required for core add/edit; there is no default pool.' },
|
|
25
25
|
},
|
|
26
26
|
required: ['action'],
|
|
27
27
|
},
|
|
@@ -13,7 +13,10 @@ import {
|
|
|
13
13
|
} from 'fs';
|
|
14
14
|
import { dirname, basename, join } from 'path';
|
|
15
15
|
import { randomBytes } from 'crypto';
|
|
16
|
-
import { execFileSync } from 'child_process';
|
|
16
|
+
import { execFile, execFileSync } from 'child_process';
|
|
17
|
+
import { promisify } from 'util';
|
|
18
|
+
|
|
19
|
+
const _execFileAsync = promisify(execFile);
|
|
17
20
|
|
|
18
21
|
const RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EEXIST']);
|
|
19
22
|
const LOCK_WAIT_CODES = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
|
|
@@ -419,7 +422,10 @@ export async function withFileLock(lockPath, fn, opts = {}) {
|
|
|
419
422
|
}
|
|
420
423
|
try { writeFileSync(fd, `${process.pid} ${Date.now()} ${_OWNER_TOKEN}\n`, 'utf8'); } catch {}
|
|
421
424
|
try {
|
|
422
|
-
|
|
425
|
+
// Async ACL enforcement (promisified execFile) so a secret-bearing
|
|
426
|
+
// async holder never blocks the event loop on icacls; identical
|
|
427
|
+
// fail-closed semantics to the sync variant.
|
|
428
|
+
if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(lockPath);
|
|
423
429
|
return await fn();
|
|
424
430
|
} finally {
|
|
425
431
|
try { closeSync(fd); } catch {}
|
|
@@ -523,6 +529,59 @@ function _enforceOwnerOnlyAclWin32(targetPath) {
|
|
|
523
529
|
}
|
|
524
530
|
}
|
|
525
531
|
|
|
532
|
+
// ── Async owner-only ACL enforcement (non-blocking, fail-closed) ─────
|
|
533
|
+
// Byte-for-byte the same policy as `_enforceOwnerOnlyAclWin32`, but the two
|
|
534
|
+
// icacls invocations (and the one-time whoami SID resolution) run through
|
|
535
|
+
// promisified execFile so the event loop is never blocked on a subprocess.
|
|
536
|
+
// Shares the `_cachedUserSid` cache with the sync variant, and throws the
|
|
537
|
+
// same EACL* codes so callers keep their fail-closed guarantees.
|
|
538
|
+
async function _resolveCurrentUserPrincipalAsync() {
|
|
539
|
+
const systemRoot = process.env.SystemRoot || process.env.windir;
|
|
540
|
+
if (systemRoot) {
|
|
541
|
+
const whoami = join(systemRoot, 'System32', 'whoami.exe');
|
|
542
|
+
if (existsSync(whoami)) {
|
|
543
|
+
try {
|
|
544
|
+
const { stdout } = await _execFileAsync(whoami, ['/user', '/fo', 'csv', '/nh'], {
|
|
545
|
+
encoding: 'utf8',
|
|
546
|
+
windowsHide: true,
|
|
547
|
+
});
|
|
548
|
+
const m = String(stdout).match(/S-1-5-[0-9-]+/);
|
|
549
|
+
if (m) return m[0];
|
|
550
|
+
} catch {}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const err = new Error('cannot resolve current Windows user for owner-only ACL enforcement');
|
|
554
|
+
err.code = 'EACLNOUSER';
|
|
555
|
+
throw err;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function _enforceOwnerOnlyAclWin32Async(targetPath) {
|
|
559
|
+
if (process.platform !== 'win32') return;
|
|
560
|
+
const systemRoot = process.env.SystemRoot || process.env.windir;
|
|
561
|
+
if (!systemRoot) {
|
|
562
|
+
const err = new Error('SystemRoot not set; cannot locate icacls.exe for owner-only ACL enforcement');
|
|
563
|
+
err.code = 'EACLNOROOT';
|
|
564
|
+
throw err;
|
|
565
|
+
}
|
|
566
|
+
const icacls = join(systemRoot, 'System32', 'icacls.exe');
|
|
567
|
+
if (!existsSync(icacls)) {
|
|
568
|
+
const err = new Error(`icacls.exe not found at ${icacls}; refusing to leave secret world-readable`);
|
|
569
|
+
err.code = 'EACLNOICACLS';
|
|
570
|
+
throw err;
|
|
571
|
+
}
|
|
572
|
+
if (_cachedUserSid === null) _cachedUserSid = await _resolveCurrentUserPrincipalAsync();
|
|
573
|
+
const principal = _icaclsPrincipal(_cachedUserSid);
|
|
574
|
+
try {
|
|
575
|
+
await _execFileAsync(icacls, [targetPath, '/reset'], { windowsHide: true });
|
|
576
|
+
await _execFileAsync(icacls, [targetPath, '/inheritance:r', '/grant:r', `${principal}:(F)`], { windowsHide: true });
|
|
577
|
+
} catch (err) {
|
|
578
|
+
const e = new Error(`icacls failed to apply owner-only ACL to ${targetPath}: ${err?.message || err}`);
|
|
579
|
+
e.code = 'EACLFAIL';
|
|
580
|
+
e.cause = err;
|
|
581
|
+
throw e;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
526
585
|
export function writeFileAtomicSync(filePath, data, opts = {}) {
|
|
527
586
|
const run = () => {
|
|
528
587
|
const dir = dirname(filePath);
|
|
@@ -648,6 +707,75 @@ export function updateJsonAtomicSync(filePath, mutator, opts = {}) {
|
|
|
648
707
|
}, opts);
|
|
649
708
|
}
|
|
650
709
|
|
|
710
|
+
// ── Async atomic file write (non-blocking secret ACL) ───────────────
|
|
711
|
+
// Mirror of writeFileAtomicSync, but the owner-only ACL enforcement on the
|
|
712
|
+
// temp and final path runs through the async icacls variant so a debounced
|
|
713
|
+
// timer flush never blocks the event loop on a subprocess. The tiny
|
|
714
|
+
// writeFileSync/renameSync of a small JSON payload stay synchronous (they are
|
|
715
|
+
// not the hitch); only the icacls calls — the actual blockers — are awaited.
|
|
716
|
+
// The truncate renameFallback branch is intentionally omitted: it only ever
|
|
717
|
+
// applied to non-secret writes (opts.secret !== true), and every async caller
|
|
718
|
+
// here is a secret config write, so behavior is identical.
|
|
719
|
+
export async function writeFileAtomicAsync(filePath, data, opts = {}) {
|
|
720
|
+
const run = async () => {
|
|
721
|
+
const dir = dirname(filePath);
|
|
722
|
+
mkdirSync(dir, { recursive: true });
|
|
723
|
+
const tmp = join(dir, `.${basename(filePath)}.${randomBytes(12).toString('hex')}.tmp`);
|
|
724
|
+
try {
|
|
725
|
+
const writeOpts = { encoding: opts.encoding || 'utf8', flag: 'wx', mode: opts.mode !== undefined ? opts.mode : 0o600 };
|
|
726
|
+
writeFileSync(tmp, data, writeOpts);
|
|
727
|
+
if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(tmp);
|
|
728
|
+
if (opts.fsync !== false) {
|
|
729
|
+
let fd = null;
|
|
730
|
+
try {
|
|
731
|
+
fd = openSync(tmp, 'r');
|
|
732
|
+
fsyncSync(fd);
|
|
733
|
+
} catch (err) {
|
|
734
|
+
if (!['EPERM', 'ENOTSUP', 'EINVAL'].includes(err?.code)) throw err;
|
|
735
|
+
} finally {
|
|
736
|
+
try { if (fd !== null) closeSync(fd); } catch {}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
if (opts.createOnly === true) {
|
|
740
|
+
try {
|
|
741
|
+
linkSync(tmp, filePath);
|
|
742
|
+
} catch (err) {
|
|
743
|
+
try { unlinkSync(tmp); } catch {}
|
|
744
|
+
if (err?.code === 'EEXIST') return false;
|
|
745
|
+
throw err;
|
|
746
|
+
}
|
|
747
|
+
try { unlinkSync(tmp); } catch {}
|
|
748
|
+
} else {
|
|
749
|
+
renameWithRetrySync(tmp, filePath, opts);
|
|
750
|
+
}
|
|
751
|
+
if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(filePath);
|
|
752
|
+
if (opts.fsyncDir === true) {
|
|
753
|
+
let dfd = null;
|
|
754
|
+
try {
|
|
755
|
+
dfd = openSync(dir, 'r');
|
|
756
|
+
fsyncSync(dfd);
|
|
757
|
+
} catch (err) {
|
|
758
|
+
if (!['EPERM', 'ENOTSUP', 'EINVAL', 'EACCES'].includes(err?.code)) throw err;
|
|
759
|
+
} finally {
|
|
760
|
+
try { if (dfd !== null) closeSync(dfd); } catch {}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return true;
|
|
764
|
+
} catch (err) {
|
|
765
|
+
try { if (existsSync(tmp)) unlinkSync(tmp); } catch {}
|
|
766
|
+
throw err;
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
if (opts.lock === true) {
|
|
770
|
+
return withFileLock(`${filePath}.lock`, run, opts);
|
|
771
|
+
}
|
|
772
|
+
return run();
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
export function writeJsonAtomicAsync(filePath, value, opts = {}) {
|
|
776
|
+
return writeFileAtomicAsync(filePath, JSON.stringify(value, null, opts.compact ? 0 : 2) + '\n', opts);
|
|
777
|
+
}
|
|
778
|
+
|
|
651
779
|
// Async read-modify-write. Same lock path (`${filePath}.lock`) and protocol
|
|
652
780
|
// as updateJsonAtomicSync, so it is mutually exclusive with the sync variant.
|
|
653
781
|
// The critical section itself is synchronous (readFileSync + writeJsonAtomicSync);
|
|
@@ -51,7 +51,7 @@ export function executionModeSchemaDescription(defaultMode = 'sync') {
|
|
|
51
51
|
if (defaultMode === 'async') {
|
|
52
52
|
return 'sync = inline result; async = task_id + completion notification. Default async.';
|
|
53
53
|
}
|
|
54
|
-
return 'Runs sync;
|
|
54
|
+
return 'Runs sync (inline result); no default auto-background. async forces a background task_id + completion notification.';
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export function taskIdFromArgs(args = {}) {
|
|
@@ -6,9 +6,10 @@ import { readFileSync, mkdirSync, existsSync } from 'fs'
|
|
|
6
6
|
import { join, dirname } from 'path'
|
|
7
7
|
import { createRequire } from 'module'
|
|
8
8
|
import { resolvePluginData } from './plugin-paths.mjs'
|
|
9
|
-
import { renameWithRetrySync, writeJsonAtomicSync, withFileLockSync } from './atomic-file.mjs'
|
|
9
|
+
import { renameWithRetrySync, writeJsonAtomicSync, writeJsonAtomicAsync, withFileLockSync, withFileLock } from './atomic-file.mjs'
|
|
10
10
|
import {
|
|
11
11
|
backupUserData,
|
|
12
|
+
backupUserDataAsync,
|
|
12
13
|
markUserDataInitialized,
|
|
13
14
|
loadLatestMixdogConfigFromBackup,
|
|
14
15
|
hasUserDataInitMarker,
|
|
@@ -210,6 +211,57 @@ export function updateSection(section, updater) {
|
|
|
210
211
|
})
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
// ── Async write path (non-blocking) ─────────────────────────────────
|
|
215
|
+
// Parity with the sync RMW above, but every heavy blocker (cross-process
|
|
216
|
+
// lock wait, owner-only icacls ACL, user-data backup copy tree) is awaited
|
|
217
|
+
// off the event loop. Reuses the SAME lock file (`${CONFIG_PATH}.lock`) and
|
|
218
|
+
// secret:true ACL protocol, so an async writer and a sync writer are mutually
|
|
219
|
+
// exclusive against one another AND against other processes — cross-process
|
|
220
|
+
// RMW linearizability is preserved. The in-lock read (readAllForRmW) stays
|
|
221
|
+
// synchronous: it is a small local read, not the hitch source.
|
|
222
|
+
async function writeJsonFileAsync(path, data) {
|
|
223
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
224
|
+
if (path === CONFIG_PATH) {
|
|
225
|
+
try { await backupUserDataAsync(DATA_DIR, 'pre-config-write') } catch {}
|
|
226
|
+
}
|
|
227
|
+
await writeJsonAtomicAsync(path, data, { lock: false, fsyncDir: true, mode: 0o600, secret: true })
|
|
228
|
+
if (path === CONFIG_PATH) {
|
|
229
|
+
try { markUserDataInitialized(DATA_DIR) } catch {}
|
|
230
|
+
try { await backupUserDataAsync(DATA_DIR, 'post-config-write') } catch {}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function writeAllAsync(data) {
|
|
235
|
+
await writeJsonFileAsync(CONFIG_PATH, data)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// secret:true clamps the shared-home lock file owner-only on win32 via the
|
|
239
|
+
// ASYNC icacls variant inside withFileLock (fail-closed, non-blocking).
|
|
240
|
+
function withConfigLockAsync(fn) {
|
|
241
|
+
return withFileLock(`${CONFIG_PATH}.lock`, fn, { secret: true })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function updateConfigAsync(updater) {
|
|
245
|
+
let saved = null
|
|
246
|
+
await withConfigLockAsync(async () => {
|
|
247
|
+
const current = stripGeneratedMarker(readAllForRmW()) || {}
|
|
248
|
+
const next = typeof updater === 'function' ? updater({ ...current }) : updater
|
|
249
|
+
if (!isPlainObject(next)) throw new Error('[config] updateConfigAsync updater must return an object')
|
|
250
|
+
saved = stripGeneratedMarker(next) || {}
|
|
251
|
+
await writeAllAsync(saved)
|
|
252
|
+
})
|
|
253
|
+
return saved
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function updateSectionAsync(section, updater) {
|
|
257
|
+
await withConfigLockAsync(async () => {
|
|
258
|
+
const all = readAllForRmW()
|
|
259
|
+
const current = stripGeneratedMarker(all[section] || {})
|
|
260
|
+
all[section] = stripGeneratedMarker(typeof updater === 'function' ? updater(current) : updater)
|
|
261
|
+
await writeAllAsync(all)
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
213
265
|
// ── Capabilities (B2 central path policy) ───────────────────────────
|
|
214
266
|
// Top-level `capabilities` section in mixdog-config.json. Safe defaults
|
|
215
267
|
// win on missing/malformed input — every cap is OFF unless explicitly
|
|
@@ -2,7 +2,7 @@ export const TOOL_SYNC_EXECUTION_CONTRACT =
|
|
|
2
2
|
'Runs synchronously in this tool call.';
|
|
3
3
|
|
|
4
4
|
export const TOOL_ASYNC_EXECUTION_CONTRACT =
|
|
5
|
-
'Runs sync;
|
|
5
|
+
'Runs sync inline; no default auto-background. async forces a background task_id + completion notification. status/read/wait are recovery/blocking only.';
|
|
6
6
|
|
|
7
7
|
export const TOOL_MANUAL_CONTROL_CONTRACT =
|
|
8
8
|
'wait/read/status/cancel are for explicit blocking or recovery only.';
|
|
@@ -583,6 +583,25 @@ export function aggregateToolCategoryEntry(name, args = {}, category = '') {
|
|
|
583
583
|
};
|
|
584
584
|
}
|
|
585
585
|
|
|
586
|
+
/**
|
|
587
|
+
* Rebuild the per-category count map for the DONE state, excluding calls that
|
|
588
|
+
* terminated in an error (rec.isError). Mirrors the call-time accumulation in
|
|
589
|
+
* turn.mjs (sum aggregateToolCategoryEntry(...).count per key) but drops failed
|
|
590
|
+
* calls so the completed label counts only successful work — across ALL
|
|
591
|
+
* categories, not just Memory. The active/in-flight header keeps using the raw
|
|
592
|
+
* `categories` map, so in-flight counting is unchanged.
|
|
593
|
+
*/
|
|
594
|
+
export function aggregateDoneCategories(calls = []) {
|
|
595
|
+
const map = new Map();
|
|
596
|
+
for (const rec of calls || []) {
|
|
597
|
+
if (!rec || rec.isError) continue;
|
|
598
|
+
const entry = aggregateToolCategoryEntry(rec.name, rec.args, rec.category);
|
|
599
|
+
const prev = map.get(entry.key);
|
|
600
|
+
map.set(entry.key, { ...entry, count: Number(prev?.count || 0) + Number(entry.count || 1) });
|
|
601
|
+
}
|
|
602
|
+
return Object.fromEntries(map);
|
|
603
|
+
}
|
|
604
|
+
|
|
586
605
|
function aggregateCount(value) {
|
|
587
606
|
if (value && typeof value === 'object') return Math.max(0, Number(value.count || 0));
|
|
588
607
|
return Math.max(0, Number(value || 0));
|
|
@@ -188,6 +188,9 @@ export function runGlobalUpdate() {
|
|
|
188
188
|
child = spawn(npmCmd, ['install', '-g', `${PACKAGE_NAME}@latest`], {
|
|
189
189
|
stdio: 'ignore',
|
|
190
190
|
windowsHide: true,
|
|
191
|
+
// detached: survive parent exit — quitting the TUI mid-install must
|
|
192
|
+
// not kill npm halfway and leave the global install corrupted.
|
|
193
|
+
detached: true,
|
|
191
194
|
shell: process.platform === 'win32',
|
|
192
195
|
});
|
|
193
196
|
} catch (err) {
|