v0.9.0 : attente unique des délégations et contrôles de MIAM

- reprise lancée une fois en arrière-plan ; suivre et sleep refusés pendant l'attente
- réveil lu par le plugin : résultat et conduite selon fin, gain compté à ce moment
- lecture entière d'une sortie au verdict valide refusée, sauf chemins sensibles (réglage chemins_sensibles)
- compteurs d'attentes, d'essais et d'échecs de contrôle dans /miam-economies
- skill deleguer : controle, attendus, reprise et tableau des fin ; README et cahier à jour

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
angeandClaude Opus 5.5 committed 2026-11-03 09:37:11 +01:00
1 parent 7d3885a735
commit 2ce4af8963
8 files changed
+513 -28

No files matched your search

+5
View File
@@ -331,6 +331,11 @@ curl -s -H @"<logs>/mcp/attente.hdr" "http://127.0.0.1:<port>/attendre?demande_i
### À coder dans le plugin (v0.9.0), par bouts
Fait le 03/11/2026 (bouts 1 à 7, `hooks/attente.ts` et `register.tsx`, tests sur réponses figées), avec deux écarts :
le réveil est lu par un hook `prompt.submit`, qui reconnaît la notification à l'id de la commande d'arrière-plan relevé
au lancement et lit lui-même la sortie (une ligne JSON) ; PowerShell est refusé pour `reprise`, en syntaxe POSIX.
Pas encore essayé sur le vrai serveur.
1. **Hook PostToolUse sur `deleguer`** : si `ok` est `null` et qu'il y a une `reprise`, ajouter au contexte « lance `reprise` une fois avec `run_in_background`, puis termine ton tour ou fais autre chose », et noter `demande_id` comme en attente dans l'état du tour.
2. **Hook PreToolUse sur `Bash`** : reconnaître une commande qui contient `/attendre?demande_id=` ; sans `run_in_background`, la refuser (« lance-la en arrière-plan ») ; avec, passer la demande à « attendue ».
3. **Hook PreToolUse sur `suivre`** : refuser si la demande est « attendue », avec « tu seras réveillé à la fin ». Refuser aussi les `sleep` et les boucles d'attente en `Bash` pendant une attente. Laisser passer `suivre` si `fin` vaut `besoin_claude` (permission à donner).
+7 -1
View File
@@ -1,7 +1,7 @@
{
"name": "miam",
"displayName": "MIAM",
"version": "0.8.0",
"version": "0.9.0",
"description": "Délégation de tâches à un modèle local géré par MIAM (Mon IA Manager) : règles de délégation, gains en tokens et usage Claude (5 h, hebdo) en couleur",
"author": {
"name": "Ange-Marie MAURIN"
@@ -21,6 +21,12 @@
"description": "Refuse à l'agent principal la lecture entière d'un long fichier et retient les longues sorties Bash/PowerShell, tant que MIAM répond",
"default": true
},
"chemins_sensibles": {
"type": "string",
"title": "Chemins sensibles",
"description": "Fichiers relus en entier même après un verdict valide de MIAM (concurrence, sécurité), séparés par des virgules ; « * » pour n'importe quels caractères",
"default": "src/miam/services/mcp.py, src/miam/services/instances.py, src/miam/core/aio.py, *sandbox*"
},
"jeton": {
"type": "string",
"title": "Jeton du serveur MCP de MIAM",
+13
View File
@@ -36,6 +36,7 @@ Puis : à l'activation, Claude Code demande les réglages (voir section suivante
| jeton | (vide) | Jeton Bearer du serveur MCP de MIAM, rangé dans le coffre d'identifiants. |
| port | 8090 | Port du serveur MCP de MIAM sur 127.0.0.1. |
| incitation | oui | Ajoute au prompt système une consigne de délégation (quelques centaines de tokens, en cache) et un rappel après une grosse lecture. |
| chemins_sensibles | `src/miam/services/mcp.py`, `src/miam/services/instances.py`, `src/miam/core/aio.py`, `*sandbox*` | Fichiers relus en entier même après un verdict `valide` (concurrence, sécurité), séparés par des virgules. |
| preset_defaut | pre-16312f8b | Preset MIAM par défaut, démarré quand aucun modèle n'est chargé et que MIAM n'a aucun test pour le type de la tâche. |
## Utilisation
@@ -53,6 +54,16 @@ Avec `imposer` (par défaut), et tant que MIAM répond, l'agent principal ne peu
- `Read` d'un fichier de 20 000 octets ou plus est refusé avant la lecture, sauf plage de 400 lignes au plus (`offset`, `limit`), image, PDF ou fichier écrit par une délégation ;
- une sortie `Bash` ou `PowerShell` de 20 000 caractères ou plus est enregistrée dans `.claude/miam-sorties/` du projet (dossier ignoré par git) et retenue : le modèle reçoit le chemin, à passer à `deleguer`. Une commande en erreur garde sa sortie et déclenche le rappel.
## Attente unique
Une délégation pas finie en 45 s renvoie `reprise`, une commande `curl` qui bloque sur MIAM jusqu'à l'état final. Le plugin dit à l'agent principal de la lancer une fois en arrière-plan (Bash, `run_in_background`), puis :
- refuse de la lancer au premier plan ou avec PowerShell ;
- refuse `suivre` et les `sleep` tant que la demande est attendue (`suivre` reste permis quand MIAM attend une décision, `fin: besoin_claude`) ;
- au réveil, lit lui-même la sortie de la commande et donne le résultat et la conduite selon `fin` (`valide`, `termine`, `echec_controle`, `besoin_claude`, `annule`, `perdue`) : une délégation coûte un lancement et un réveil ;
- après un verdict `valide`, refuse la lecture entière du fichier `sortie` (plage de 400 lignes au plus), sauf chemin sensible.
`/miam-economies` compte les attentes uniques, les essais et les échecs de contrôle de la session. Avec `imposer` désactivé, rien n'est refusé.
Les subagents ne sont pas concernés. Si MIAM ne répond pas, ou si la sortie ne peut pas être enregistrée, rien n'est bloqué. Si MIAM répond mais que la délégation échoue (`/mcp` à reconnecter, par exemple), la lecture par plages reste possible.
## Ligne d'état : gains et usage Claude
@@ -93,6 +104,8 @@ hooks/
hooks.json
register.tsx # compteur de tokens, ligne d'état, incitation, /miam-economies
economies.ts # calcul pur, testé
attente.ts # attente unique et conduite selon `fin` : calcul pur, testé
contrainte.ts # seuils des lectures et sorties longues
usage.ts # usage Claude 5 h et hebdo : calcul pur, testé
*.test.ts(x) # tests : claude plugin test plugins/miam
LICENSE
+60
View File
@@ -0,0 +1,60 @@
import { expect, test } from 'claude-code/testing'
import {
conduite, demandeAttendue, estSommeil, fichierSortie, finDe, lectureEntiere, lireFin, sensible,
} from './attente'
const REPRISE = 'curl -s -H @"C:/logs/mcp/attente.hdr" "http://127.0.0.1:8090/attendre?demande_id=dem-1a2b"'
test('la commande reprise désigne sa demande, une autre commande aucune', () => {
expect(demandeAttendue(REPRISE)).toBe('dem-1a2b')
expect(demandeAttendue('curl -s http://127.0.0.1:8090/etat')).toBeUndefined()
})
test('sleep, Start-Sleep et timeout /t sont des attentes, pas un mot qui les contient', () => {
expect(['sleep 30', 'while true; do sleep 5; done', 'Start-Sleep -Seconds 10', 'timeout /t 20'].map(estSommeil))
.toEqual([true, true, true, true])
expect(['ls asleep', 'grep sleeper src', 'pytest -k timeout'].map(estSommeil)).toEqual([false, false, false])
})
test('lireFin prend la dernière ligne JSON avec un fin connu', () => {
const sortie = 'bruit\n{"fin":"bizarre"}\n{"ok":true,"demande_id":"dem-1","verdict":"valide","fin":"valide"}\n'
expect(lireFin(sortie)).toMatchObject({ fin: 'valide', demande_id: 'dem-1' })
expect(lireFin('curl: (7) Failed to connect')).toBeUndefined()
})
test('finDe : seulement ce que MIAM a contrôlé', () => {
expect(finDe({ ok: true, verdict: 'valide' })).toBe('valide')
expect(finDe({ ok: true, verdict: 'non_controle' })).toBe('termine')
expect(finDe({ ok: true })).toBeUndefined() // ancien MIAM, sans contrôle
expect(finDe({ ok: false, erreur: 'echec_controle : lint : F401' })).toBe('echec_controle')
expect(finDe({ ok: false, erreur: 'aucun preset testé' })).toBeUndefined()
expect(finDe({ ok: null })).toBeUndefined()
})
test('conduite : une ligne par fin, avec ce qui sert à agir', () => {
expect(conduite({ fin: 'valide', demande_id: 'd', essais: 2, sortie: 'E:/p/x.py' }))
.toContain('résultat écrit dans E:/p/x.py')
expect(conduite({ fin: 'echec_controle', erreur: 'echec_controle : lint : F401' })).toContain('lint : F401')
expect(conduite({ fin: 'echec_controle', controles: [{ nom: 'symboles', ok: false, message: 'champ x' }] }))
.toContain('symboles : champ x')
expect(conduite({ fin: 'besoin_claude' }, REPRISE)).toContain(REPRISE)
for (const fin of ['termine', 'annule', 'perdue'] as const) {
expect(conduite({ fin }).length).toBeGreaterThan(0)
}
})
test('lecture entière sans limit bornée ; chemins sensibles par fin de chemin ou motif', () => {
expect([{}, { limit: 2000 }, { offset: 10, limit: 50 }].map(lectureEntiere)).toEqual([true, true, false])
expect(sensible('E:\\dev\\miam\\src\\miam\\services\\mcp.py')).toBe(true)
expect(sensible('E:/dev/miam/src/miam/infra/sandbox_shell.py')).toBe(true)
expect(sensible('E:/dev/miam/src/miam/services/mcp_card.py')).toBe(false)
expect(sensible('E:/p/a.py', ['*.py'])).toBe(true)
})
test('fichier de sortie nommé par la notification', () => {
const n = '<task-notification><task-id>b7</task-id><output-file>C:\\Temp\\b7.output</output-file>' +
'<status>completed</status></task-notification>'
expect(fichierSortie(n)).toBe('C:\\Temp\\b7.output')
expect(fichierSortie('rien')).toBeUndefined()
})
+130
View File
@@ -0,0 +1,130 @@
// Attente unique d'une délégation (contrat de MIAM : docs/hooks-delegation.md, section 11).
//
// Pas finie dans `call_wait_s`, une délégation renvoie `reprise`, une commande curl qui bloque sur GET /attendre jusqu'à
// l'état final. Lancée une fois en arrière-plan, elle réveille Claude à sa sortie : aucun `suivre` ni `sleep` entre-temps.
// Calcul pur, testé ; register.tsx branche ces fonctions sur les hooks.
import { PLAGE_MAX } from './contrainte'
export type Fin = 'valide' | 'termine' | 'echec_controle' | 'besoin_claude' | 'annule' | 'perdue'
export type Controle = { nom: string; ok: boolean; message?: string }
// Réponse de GET /attendre : celle de `suivre`, plus `fin`.
export type Final = {
fin: Fin
demande_id?: string
ok?: boolean | null
verdict?: string
controles?: Controle[]
essais?: number
sortie?: string
erreur?: string
[cle: string]: unknown
}
const FINS: readonly string[] = ['valide', 'termine', 'echec_controle', 'besoin_claude', 'annule', 'perdue']
// Demande attendue par une commande : l'id après `/attendre?demande_id=`.
export function demandeAttendue(commande: string): string | undefined {
return /\/attendre\?demande_id=([\w-]+)/.exec(commande)?.[1]
}
// Commande d'attente active : `sleep`, `Start-Sleep`, `timeout /t`, seuls ou dans une boucle.
export const estSommeil = (commande: string) => /(^|[\s;&|(])(sleep|start-sleep|timeout\s+\/t)\b/i.test(commande)
// Dernière ligne JSON de la sortie de curl, si elle porte un `fin` connu.
export function lireFin(sortie: string): Final | undefined {
const lignes = sortie.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
for (const ligne of lignes.reverse()) {
try {
const r = JSON.parse(ligne) as Partial<Final>
if (r && typeof r === 'object' && typeof r.fin === 'string' && FINS.includes(r.fin)) {
return r as Final
}
} catch {
// ligne qui n'est pas du JSON
}
}
return undefined
}
// Fin d'une réponse directe de `deleguer` ou `suivre` (sans passer par /attendre), seulement quand MIAM a contrôlé :
// un refus ordinaire (`ok: false`, aucun preset…) ou un résultat sans `verdict` (ancien MIAM) n'en a pas.
export function finDe(r: { ok?: unknown; verdict?: unknown; erreur?: unknown }): Fin | undefined {
if (r.ok === true && typeof r.verdict === 'string') {
return r.verdict === 'valide' ? 'valide' : 'termine'
}
if (r.ok === false && typeof r.erreur === 'string' && r.erreur.startsWith('echec_controle')) {
return 'echec_controle'
}
return undefined
}
const echecs = (r: Final) => (r.controles ?? []).filter(c => !c.ok).map(c => `${c.nom}${c.message ? ` : ${c.message}` : ''}`)
// Ce que Claude lit au réveil : le résultat utile et la conduite à tenir, pour qu'il n'ait pas à ouvrir la sortie.
export function conduite(r: Final, reprise?: string): string {
const id = r.demande_id ? ` ${r.demande_id}` : ''
const essais = r.essais ? `, ${r.essais} essai(s)` : ''
switch (r.fin) {
case 'valide':
return `MIAM${id} : verdict valide, contrôles passés${essais}` +
(r.sortie ? `, résultat écrit dans ${r.sortie}` : '') +
`. Intègre-le sans relire son corps (aperçu ou \`offset\`/\`limit\` si besoin) ; ruff et les tests du projet ` +
`suffisent comme vérification.`
case 'termine':
return `MIAM${id} : terminé sans contrôle automatique. Vérifie le résultat avant de l'intégrer.`
case 'echec_controle':
return `MIAM${id} : échec des contrôles après toutes les tentatives (${r.erreur ?? echecs(r).join(' ; ')}). ` +
`Reprends la tâche toi-même ou redécoupe-la en bouts plus petits, avec ce motif dans la consigne.`
case 'besoin_claude':
return `MIAM${id} attend une décision de ta part (permission OpenCode, plafond de dépense, consigne ambiguë) : ` +
`\`suivre\` pour la voir, réponds (\`autoriser\` pour une permission), puis relance ` +
(reprise ? `\`${reprise}\`` : 'la commande `reprise`') + ' en arrière-plan.'
case 'annule':
return `MIAM${id} : demande annulée, rien à faire.`
case 'perdue':
return `MIAM${id} : demande perdue (MIAM redémarré ou injoignable). Renvoie-la, ou fais la tâche toi-même.`
}
}
export const consigneReprise = (reprise: string) =>
`Délégation en cours. Lance une seule fois, avec Bash et \`run_in_background: true\` : \`${reprise}\`. ` +
"Puis termine ton tour ou fais autre chose : tu seras réveillé à la fin. Pas de `suivre` ni de `sleep` d'ici là."
export const REFUS_PREMIER_PLAN = "Attente refusée par le plugin MIAM : lance cette commande en arrière-plan, avec " +
"Bash et `run_in_background: true`. Elle bloque jusqu'à la fin de la délégation, et tu seras réveillé à sa sortie."
export const REFUS_POWERSHELL = "Attente refusée par le plugin MIAM : la commande `reprise` est en syntaxe POSIX. " +
'Lance-la telle quelle avec Bash et `run_in_background: true`.'
export const REFUS_SUIVI = 'Suivi refusé par le plugin MIAM : cette délégation est attendue en arrière-plan, tu ' +
'seras réveillé à la fin. Termine ton tour ou fais autre chose.'
export const REFUS_SOMMEIL = "Attente refusée par le plugin MIAM : une délégation est attendue en arrière-plan et tu " +
'seras réveillé à la fin. Pas de `sleep` ni de boucle : termine ton tour ou fais autre chose.'
export const REFUS_LECTURE_VALIDE = 'Lecture refusée par le plugin MIAM : ce fichier a passé les contrôles de MIAM ' +
`(verdict valide). Lis-en seulement la partie utile (\`offset\` et \`limit\` de ${PLAGE_MAX} lignes au plus), ou ` +
'vérifie-le par ruff et les tests.'
// Lecture entière d'un fichier : pas de `limit` borné.
export const lectureEntiere = (args: { limit?: unknown }) =>
!(typeof args.limit === 'number' && args.limit > 0 && args.limit <= PLAGE_MAX)
// Chemin sensible (concurrence, sécurité) : relu en entier même après un verdict valide. `*` vaut n'importe quels
// caractères ; un motif sans `*` désigne la fin du chemin.
export const SENSIBLES = ['src/miam/services/mcp.py', 'src/miam/services/instances.py', 'src/miam/core/aio.py', '*sandbox*']
export function sensible(chemin: string, motifs: readonly string[] = SENSIBLES): boolean {
const p = chemin.replace(/\\/g, '/').toLowerCase()
return motifs.some(m => {
const motif = m.replace(/\\/g, '/').toLowerCase()
if (!motif.includes('*')) {
return p === motif || p.endsWith(`/${motif}`)
}
const re = new RegExp(`(^|/)${motif.split('*').map(s => s.replace(/[.+?^${}()|[\]]/g, '\\$&')).join('.*')}$`)
return re.test(p)
})
}
// Notification de fin d'une commande d'arrière-plan : son fichier de sortie, si elle le nomme.
export const fichierSortie = (notification: string) =>
/<output-file>\s*([^<]+?)\s*<\/output-file>/.exec(notification)?.[1]
+113
View File
@@ -397,3 +397,116 @@ test('pilotage réel : prorata, relecture du diff, requête perdue, et rien pour
await requete($) // sans appel de suivi : rien
expect(pilotage()).toBe(50 + 25 + 500 + COUT)
})
const BASH = 'Bash'
const REPRISE_CMD = 'curl -s -H @"C:/logs/mcp/attente.hdr" "http://127.0.0.1:8090/attendre?demande_id=dem-a"'
const NOTIF = '<task-notification><task-id>b9</task-id><output-file>C:/Temp/b9.output</output-file>' +
'<status>completed</status></task-notification>'
// Monte un faux moteur pour l'attente unique ; `finale` est la ligne que la commande reprise écrit à sa sortie.
function moteurAttente(on: any, finale: Record<string, unknown>) {
const vu = { toasts: [] as string[], contextes: [] as (readonly string[] | undefined)[], appels: [] as string[] }
const store = new Map<string, unknown>()
const lus: string[] = []
on('store.get', (_: unknown, e: { key: string }) => ({ value: store.get(e.key) }))
on('store.set', (_: unknown, e: { key: string; value: unknown }) => (store.set(e.key, e.value), { value: undefined }))
on('ui.status', () => ({ value: undefined }))
on('ui.toast', (_: unknown, e: { text: string }) => (vu.toasts.push(e.text), { value: undefined }))
on('session.id', () => ({ value: 's1' }))
on('clock.now', () => ({ value: 0 }))
on('command.register', () => ({ value: { command: 'miam-economies' } }))
on('session.start', (_: unknown, e: { cwd: string }) => ({ cwd: e.cwd }))
on('session.usage', () => ({ value: { startedAt: 0, context: {}, rateLimits: [] } }))
on('clock.every', () => new Promise(() => {}))
on('http.fetch', () => ({ value: { status: 200, ok: true, headers: {}, text: '' } }))
on('fs.stat', () => ({ value: { kind: 'file', size: 100 } }))
on('fs.read', (_: unknown, e: { path: string }) => { // chemin tel que le moteur le normalise
lus.push(e.path)
return { value: /b9\.output$/.test(e.path) ? `${JSON.stringify(finale)}\n` : '' }
})
on('prompt.submit', (_: unknown, e: { text: string; context?: readonly string[] }) =>
(vu.contextes.push(e.context), { text: e.text, context: e.context }))
on('tool.call', (_: unknown, e: { tool: string }) => {
vu.appels.push(e.tool)
return e.tool === OUTIL
? { result: {}, text: JSON.stringify({ ok: null, demande_id: 'dem-a', etat: 'en_cours', reprise: REPRISE_CMD }) }
: e.tool === BASH
? { result: { stdout: '', stderr: '', interrupted: false, backgroundTaskId: 'b9' }, text: 'lancée en arrière-plan' }
: { result: {}, text: 'contenu' }
})
return { vu, store, lus }
}
const reveil = ($: any) => $.prompt.submit({ text: NOTIF, wait: false, origin: { kind: 'task-notification' } })
test('attente unique : reprise en arrière-plan, ni suivre ni sleep, réveil lu par le plugin, résultat valide non relu',
{ options: { jeton: 'x' } }, async ($, on) => {
const { vu, store, lus } = moteurAttente(on, {
ok: true, demande_id: 'dem-a', tokens_entree: 2100, tokens_sortie: 400, verdict: 'valide', essais: 2,
controles: [{ nom: 'lint', ok: true }], sortie: 'E:/p/x.py', fin: 'valide',
})
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
const delegue = await $.tool.call({ tool: OUTIL, consigne: 'c'.repeat(400) } as never)
expect(delegue.context?.join('\n')).toContain('run_in_background: true')
expect(delegue.context?.join('\n')).toContain(REPRISE_CMD)
expect((await $.tool.call({ tool: BASH, command: REPRISE_CMD } as never)).deny).toContain('arrière-plan')
expect((await $.tool.call({ tool: 'PowerShell', command: REPRISE_CMD, run_in_background: true } as never)).deny)
.toContain('Bash')
expect((await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)).deny)
.toBeUndefined()
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toContain('réveillé')
expect((await $.tool.call({ tool: BASH, command: 'sleep 60' } as never)).deny).toContain('sleep')
expect(vu.appels.filter(t => t === SUIVRE)).toEqual([]) // refusé avant d'atteindre MIAM
await reveil($)
expect(lus.some(p => /b9\.output$/.test(p))).toBe(true) // la sortie nommée par la notification
const lu = vu.contextes.at(-1)?.join('\n') ?? ''
expect(lu).toContain('inutile de')
expect(lu).toContain('verdict valide')
expect(lu).toContain('E:/p/x.py')
expect(vu.toasts.at(-1)).toContain('tokens évités')
expect((store.get('cumul') as { delegations: number }).delegations).toBe(1)
expect((await $.tool.call({ tool: 'Read', file_path: 'E:/p/x.py' } as never)).deny).toContain('verdict valide')
expect((await $.tool.call({ tool: 'Read', file_path: 'E:/p/x.py', offset: 1, limit: 40 } as never)).deny)
.toBeUndefined()
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toBeUndefined() // fini
expect((await $.tool.call({ tool: BASH, command: 'sleep 1' } as never)).deny).toBeUndefined()
const bilan = await $.command.run({ command: 'miam-economies', args: '' } as never)
expect((bilan as { text: string }).text).toContain('1 attente(s) unique(s), 2 essai(s), 0 échec(s)')
})
test('attente unique : besoin_claude rouvre suivre, echec_controle compte sans gain, un sous-agent n’est pas freiné',
{ options: { jeton: 'x' } }, async ($, on) => {
const finale: Record<string, unknown> = { ok: null, demande_id: 'dem-a', fin: 'besoin_claude' }
const { vu, store } = moteurAttente(on, finale)
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
await $.tool.call({ tool: OUTIL, consigne: 'c' } as never)
await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)
expect((await $.tool.call({ tool: BASH, command: 'sleep 5', agentId: 'a1' } as never)).deny).toBeUndefined()
await reveil($)
expect(vu.contextes.at(-1)?.join('\n')).toContain(REPRISE_CMD) // relancer après avoir répondu
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toBeUndefined()
await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)
Object.assign(finale, { ok: false, fin: 'echec_controle', erreur: 'echec_controle : symboles : champ x inventé' })
await reveil($)
expect(vu.contextes.at(-1)?.join('\n')).toContain('symboles : champ x inventé')
expect(store.get('cumul')).toBeUndefined() // aucun gain compté
const bilan = await $.command.run({ command: 'miam-economies', args: '' } as never)
expect((bilan as { text: string }).text).toContain('2 attente(s) unique(s), 0 essai(s), 1 échec(s)')
})
test('imposer=false : l’attente est suivie mais rien n’est refusé', { options: { jeton: 'x', imposer: false } },
async ($, on) => {
moteurAttente(on, {})
await $.session.start({ cwd: '/', surface: 'vscode', isInteractive: true })
await $.tool.call({ tool: OUTIL, consigne: 'c' } as never)
expect((await $.tool.call({ tool: BASH, command: REPRISE_CMD } as never)).deny).toBeUndefined()
await $.tool.call({ tool: BASH, command: REPRISE_CMD, run_in_background: true } as never)
expect((await $.tool.call({ tool: SUIVRE, demande_id: 'dem-a' } as never)).deny).toBeUndefined()
})
+161 -25
View File
@@ -5,6 +5,11 @@ import {
evenementPilotage, ligne, lireResultat, lot, mesurerCorrection, net, pastille, repartir, retirer, VIDE,
} from './economies'
import type { Evenement, Suivi, Totaux } from './economies'
import {
conduite, consigneReprise, demandeAttendue, estSommeil, fichierSortie, finDe, lectureEntiere, lireFin,
REFUS_LECTURE_VALIDE, REFUS_POWERSHELL, REFUS_PREMIER_PLAN, REFUS_SOMMEIL, REFUS_SUIVI, sensible, SENSIBLES,
} from './attente'
import type { Fin } from './attente'
import { lectureTropLongue, nomSortie, PLAGE_MAX, sortieTropLongue } from './contrainte'
import { fenetres, ligneFenetre } from './usage'
@@ -103,6 +108,78 @@ async function envoyer($: EngineInterface, miam: Miam): Promise<void> {
}
}
// État des délégations de la session, partagé par les hooks et les fonctions ci-dessous.
type Delegations = {
ecran: Ecran
enFile: Map<string, Demande>
comptees: Set<string>
subagents: Map<string, string[]>
sorties: Map<string, string>
opencode: Map<string, { pilotage: number; evites: number }>
attentes: Map<string, 'reprise' | 'attendue' | 'besoin_claude'>
commandesReprise: Map<string, string>
finies: Set<string>
valides: Set<string>
mesure: { attentes: number; essais: number; echecs: number }
}
// État final d'une délégation (réponse directe, ou sortie de /attendre au réveil) : gain, compteurs, et la conduite
// à tenir, que Claude lit à la place de la réponse. Rien quand MIAM n'a rien contrôlé et que rien n'a été attendu.
async function terminer($: EngineInterface, d: Delegations, r: Record<string, unknown>, demande: Demande | undefined):
Promise<string | undefined> {
const id = typeof r.demande_id === 'string' ? r.demande_id : undefined
const fin = (typeof r.fin === 'string' ? r.fin : finDe(r)) as Fin | undefined
if (id && fin === 'besoin_claude') {
d.attentes.set(id, 'besoin_claude')
} else if (id && fin) {
d.attentes.delete(id)
if (!d.finies.has(id)) {
d.finies.add(id)
d.mesure.echecs += fin === 'echec_controle' ? 1 : 0
d.mesure.essais += typeof r.essais === 'number' ? r.essais : 0
}
}
if (fin === 'valide' && typeof r.sortie === 'string') {
d.valides.add(cheminNormal(r.sortie))
}
if (demande) {
await compter($, d, r, demande)
}
return fin ? conduite({ ...r, fin }, id ? d.commandesReprise.get(id) : undefined) : undefined
}
// Gain d'une délégation finie, compté une fois (suivre peut renvoyer deux fois un résultat).
async function compter($: EngineInterface, d: Delegations, texte: unknown, demande: Demande): Promise<void> {
const r = lireResultat(texte)
if (!r?.demande_id || d.comptees.has(r.demande_id)) {
return
}
d.comptees.add(r.demande_id)
d.enFile.delete(r.demande_id)
const fichier = r.sortie ?? r.diff
if (fichier) {
d.sorties.set(cheminNormal(fichier), r.demande_id)
}
if (demande.agentId) {
d.subagents.set(demande.agentId, [...(d.subagents.get(demande.agentId) ?? []), r.demande_id])
}
const est = estimer(demande.consigne, demande.retour, r, demande.agentId !== undefined)
await afficher($, d.ecran, { delegations: 1, evites: est.evites })
const pilote = d.opencode.get(r.demande_id)
if (pilote) {
pilote.evites = est.evites
$.ui.toast(`${pastille(net(d.ecran.session))}MIAM : ~${court(est.evites)} tokens évités par OpenCode ` +
`(${court(est.lus)} lus, ${court(est.ecrits)} écrits, pilotage ${court(pilote.pilotage)})`)
} else {
$.ui.toast(`${pastille(net(d.ecran.session))}MIAM : ~${court(est.evites)} tokens évités (${court(est.lus)} lus, ` +
`${court(est.ecrits)} écrits)`)
}
const contexte = { sessionId: await $.session.id(), agentId: demande.agentId, retour: demande.retour }
await mettreEnAttente($, [evenementDelegation({ ...contexte, maintenant: await $.clock.now() },
{ ...r, demande_id: r.demande_id }, est)])
}
export const register: Register = (on, options) => {
const miam: Miam = { url: `http://127.0.0.1:${options.port ?? 8090}/economies`, jeton: String(options.jeton ?? '') }
const ecran: Ecran = { session: VIDE, limites: [] }
@@ -113,11 +190,24 @@ export const register: Register = (on, options) => {
const opencode = new Map<string, { pilotage: number; evites: number }>() // délégations à OpenCode de cette session
let suivis: Suivi[] = [] // appels de suivi d'OpenCode à imputer à la prochaine requête
let appels = 0 // outils lancés par la dernière requête de l'agent principal
// Attente unique : demande → « reprise » reçue, « attendue » (curl en arrière-plan) ou « besoin_claude ».
const attentes = new Map<string, 'reprise' | 'attendue' | 'besoin_claude'>()
const commandesReprise = new Map<string, string>() // demande_id → sa commande `reprise`
const taches = new Map<string, string>() // id de la commande d'arrière-plan → demande_id attendue
const finies = new Set<string>() // demande_id dont l'état final est déjà pris en compte
const valides = new Set<string>() // fichiers `sortie` au verdict valide (chemin normalisé)
const mesure = { attentes: 0, essais: 0, echecs: 0 } // pour la mesure de la v0.9.0, par session
const d: Delegations = {
ecran, enFile, comptees, subagents, sorties, opencode, attentes, commandesReprise, finies, valides, mesure,
}
let reprises = 0 // id de secours d'une correction sans tool_use_id
let envoiEnCours = false
let dernierRappel = -Infinity // date du dernier rappel envoyé au modèle
const incitation = options.incitation !== false
const imposer = options.imposer !== false
const motifsSensibles = typeof options.chemins_sensibles === 'string'
? options.chemins_sensibles.split(/[,;]/).map(m => m.trim()).filter(Boolean)
: SENSIBLES
let dossierSorties = DOSSIER_SORTIES
on('session.start', async ($, e, next) => {
@@ -155,6 +245,8 @@ export const register: Register = (on, options) => {
`Cumul : ${cumul.delegations} délégation(s), ~${court(cumul.evites)} tokens évités, pilotage ` +
`${court(cumul.pilotage)}, corrections ${court(cumul.corrections)}, net ~${court(net(cumul))}.`,
attente.length ? `${attente.length} événement(s) en attente d'envoi à MIAM.` : 'Tout est envoyé à MIAM.',
`Contrôles de la session : ${mesure.attentes} attente(s) unique(s), ${mesure.essais} essai(s), ` +
`${mesure.echecs} échec(s) de contrôle.`,
'Estimation : fichiers lus + réponse écrite par le modèle local, moins la consigne et ce que Claude relit ; ' +
'OpenCode : tokens du cloud moins la consigne. Pilotage = consommation du subagent (lecture en cache comptée ' +
'au dixième) et, pour OpenCode, usage réel des requêtes de suivi, contexte relu compris ; corrections = ce que ' +
@@ -167,6 +259,33 @@ export const register: Register = (on, options) => {
on('tool.call', async ($, e, next) => {
const outil = OUTIL.exec(e.tool)?.[1]
if (!outil) {
// Attente unique : la commande `reprise` part une fois en arrière-plan, et rien ne guette la fin à sa place.
if (e.agentId === undefined && SHELL.test(e.tool)) {
const shell = e as unknown as { command?: unknown; run_in_background?: unknown }
const commande = String(shell.command ?? '')
const attendue = demandeAttendue(commande)
if (attendue) {
if (imposer && e.tool !== 'Bash') {
return { deny: REFUS_POWERSHELL }
}
if (imposer && shell.run_in_background !== true) {
return { deny: REFUS_PREMIER_PLAN }
}
const ran = await next(e)
if (ran.deny === undefined && !ran.isError) {
attentes.set(attendue, 'attendue')
mesure.attentes += 1
const tache = (ran.result as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId
if (typeof tache === 'string') {
taches.set(tache, attendue)
}
}
return ran
}
if (imposer && estSommeil(commande) && [...attentes.values()].includes('attendue')) {
return { deny: REFUS_SOMMEIL }
}
}
// Corrections : l'agent principal relit ou retouche une sortie de MIAM ; ce coût se déduit du gain. Celles du
// subagent sont déjà dans son pilotage.
if (e.agentId !== undefined || !REPRISE.test(e.tool)) {
@@ -174,6 +293,12 @@ export const register: Register = (on, options) => {
}
const args = e as unknown as Record<string, unknown>
const demandeId = sorties.size ? cible(e.tool, args, sorties) : undefined
// Un résultat au verdict valide ne se relit pas en entier, sauf sur un chemin sensible.
const chemin = String(args.file_path ?? '')
if (imposer && e.tool === 'Read' && valides.has(cheminNormal(chemin)) && lectureEntiere(args) &&
!sensible(chemin, motifsSensibles)) {
return { deny: REFUS_LECTURE_VALIDE }
}
// Un long fichier ne se lit pas en entier, sauf s'il vient de MIAM : le refus part avant la lecture.
if (imposer && e.tool === 'Read' && !demandeId) {
const stat = await $.fs.stat(String(args.file_path), { resolve: false }).catch(() => undefined)
@@ -219,6 +344,11 @@ export const register: Register = (on, options) => {
}, correction)])
return ran
}
// Délégation attendue en arrière-plan : le réveil viendra seul. Une décision demandée (besoin_claude) reste ouverte.
const demandeSuivie = (e as unknown as { demande_id?: string }).demande_id ?? ''
if (imposer && outil === 'suivre' && e.agentId === undefined && attentes.get(demandeSuivie) === 'attendue') {
return { deny: REFUS_SUIVI }
}
const ran = await next(e)
if (ran.deny !== undefined || ran.isError) {
return ran
@@ -258,37 +388,43 @@ export const register: Register = (on, options) => {
return ran
}
if (brut?.ok == null && brut?.demande_id) {
enFile.set(brut.demande_id, demande) // en file : compté quand suivre rendra le résultat
enFile.set(brut.demande_id, demande) // en file : compté au résultat (suivre, ou réveil de l'attente)
const reprise = (brut as { reprise?: unknown }).reprise
if (typeof reprise === 'string' && e.agentId === undefined) {
attentes.set(brut.demande_id, 'reprise')
commandesReprise.set(brut.demande_id, reprise)
return { ...ran, context: [...(ran.context ?? []), consigneReprise(reprise)] }
}
return ran
}
const r = lireResultat(texte)
if (!r?.demande_id || comptees.has(r.demande_id)) {
return ran
const conseil = await terminer($, d, texte as Record<string, unknown>, demande)
return conseil && e.agentId === undefined ? { ...ran, context: [...(ran.context ?? []), conseil] } : ran
})
// Réveil : la commande `reprise` est finie. Le plugin lit sa sortie (une ligne JSON avec `fin`) et donne à Claude le
// résultat et la conduite, pour qu'il n'ait ni à ouvrir cette sortie ni à appeler `suivre`.
on('prompt.submit', async ($, e, next) => {
// Reconnue à l'id de la commande d'arrière-plan relevé au lancement, ou à son enveloppe qui cite la route.
if (!attentes.size) {
return next(e)
}
comptees.add(r.demande_id)
enFile.delete(r.demande_id)
const fichier = r.sortie ?? r.diff
if (fichier) {
sorties.set(cheminNormal(fichier), r.demande_id)
const tache = [...taches].find(([t]) => e.text.includes(t))
const id = tache?.[1] ?? (e.text.includes('<task-notification') ? demandeAttendue(e.text) : undefined)
if (!id || attentes.get(id) !== 'attendue') {
return next(e)
}
if (demande.agentId) {
subagents.set(demande.agentId, [...(subagents.get(demande.agentId) ?? []), r.demande_id])
if (tache) {
taches.delete(tache[0])
}
const est = estimer(demande.consigne, demande.retour, r, demande.agentId !== undefined)
await afficher($, ecran, { delegations: 1, evites: est.evites })
const pilote = opencode.get(r.demande_id)
if (pilote) {
pilote.evites = est.evites
$.ui.toast(`${pastille(net(ecran.session))}MIAM : ~${court(est.evites)} tokens évités par OpenCode ` +
`(${court(est.lus)} lus, ${court(est.ecrits)} écrits, pilotage ${court(pilote.pilotage)})`)
} else {
$.ui.toast(`${pastille(net(ecran.session))}MIAM : ~${court(est.evites)} tokens évités (${court(est.lus)} lus, ` +
`${court(est.ecrits)} écrits)`)
const chemin = fichierSortie(e.text)
const final = chemin ? lireFin(await $.fs.read(chemin).catch(() => '')) : undefined
if (!final) {
attentes.delete(id) // sortie illisible : Claude la lit lui-même, et `suivre` redevient possible
return next(e)
}
const contexte = { sessionId: await $.session.id(), agentId: demande.agentId, retour: demande.retour }
await mettreEnAttente($, [evenementDelegation({ ...contexte, maintenant: await $.clock.now() },
{ ...r, demande_id: r.demande_id }, est)])
return ran
const conseil = await terminer($, d, { demande_id: id, ...final }, enFile.get(id))
const lu = `Sortie de l'attente lue par le plugin MIAM, inutile de l'ouvrir. ${conseil}`
return next({ ...e, context: [...(e.context ?? []), lu] })
})
on('turn.step', async function* ($, e, next) {
+24 -2
View File
@@ -30,9 +30,31 @@ pilotage coûte plus de tokens que la délégation n'en économise. Le modèle l
- `fichiers` : **chemins absolus**, `chemin:debut-fin` pour une partie. MIAM les lit : ne les lis pas avant ;
- `format` : `json` pour des faits extraits, `code` pour du code, sinon `texte` ;
- réponse longue : `sortie` + `retour="apercu"`, pour ne pas la relire ;
- `reflexion` : `off` par défaut, `bas`/`moyen`/`haut` si la tâche demande du raisonnement.
- `reflexion` : `off` par défaut, `bas`/`moyen`/`haut` si la tâche demande du raisonnement ;
- `controle` : contrôles de MIAM parmi `syntaxe`, `complet`, `symboles`, `docstrings`, `citations`, `traduction`, `lint`
(`[]` pour aucun). Par défaut, du code avec une `sortie` `.py` passe `syntaxe`, `complet`, `symboles`, `docstrings` et
`lint` ; le reste passe `complet` ;
- `attendus` : noms qui doivent figurer dans la réponse (fonctions, classes), vérifiés par `complet` ;
- `symboles` et `citations` comparent la réponse aux `fichiers` : joins les modules que le code utilise.
Réponse « en file » : `suivre(demande_id, attente_s=45)` jusqu'au résultat, `annuler(demande_id)` pour abandonner.
MIAM retente seul en cas d'échec (2 essais, puis l'API pour une demande locale) et n'écrit la `sortie` qu'après un
verdict `valide`.
**Réponse « en file »** (`ok: null` avec `reprise`) : lance la commande `reprise` **une seule fois**, telle quelle, avec
Bash et `run_in_background: true`, puis termine ton tour ou fais autre chose. Tu es réveillé à la fin, et le plugin te
donne alors le résultat : inutile d'ouvrir la sortie de la commande. Ni `suivre` ni `sleep` d'ici là (le plugin les
refuse). Au réveil, selon `fin` :
| `fin` | Conduite |
|---|---|
| `valide` | contrôles passés : intègre sans relire le corps (aperçu ou `offset`/`limit`), ruff et tests du projet ensuite |
| `termine` | pas de contrôle automatique : vérifie avant d'intégrer |
| `echec_controle` | reprends la main, ou redécoupe en bouts plus petits avec le motif dans la consigne |
| `besoin_claude` | `suivre` pour voir la décision attendue, réponds (`autoriser`), puis relance `reprise` en arrière-plan |
| `annule` | rien |
| `perdue` | MIAM redémarré ou injoignable : renvoie la demande ou fais-la toi-même |
Sans `reprise` (ancien MIAM) : `suivre(demande_id, attente_s=45)` jusqu'au résultat. `annuler(demande_id)` abandonne.
Refus de `deleguer` : `aucun preset testé` → `demarrer(preset="${user_config.preset_defaut}")`, `suivre`, puis le même
`deleguer` ; contexte dépassé → `modeles`, puis `preset` = le mieux noté dont le `contexte` suffit, sans `offload` si