pantheon-opencode 1.1.1-beta.9 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/opencode.json +49 -2
- package/package.json +1 -1
- package/pantheon.schema.json +1 -1
- package/scripts/install-mcp.mjs +54 -18
- package/scripts/mcp_persistence_server.py +3 -3
- package/src/agents/mnemosyne.md +9 -1
- package/src/agents/nyx.md +7 -1
- package/src/agents/themis.md +8 -2
- package/src/agents/zeus.md +10 -0
- package/src/mcp/install-mcp.mjs +54 -18
- package/src/mcp/mcp_persistence_server.py +3 -3
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +208 -112
- package/src/plugins/tui/dist/tui.tsx +162 -45
- package/src/plugins/tui/src/index.tsx +162 -45
- package/src/routing.yml +11 -30
- package/commands/pantheon-audit.md +0 -25
- package/commands/pantheon-deepwork.md +0 -79
- package/commands/pantheon-focus.md +0 -9
- package/commands/pantheon-forget.md +0 -58
- package/commands/pantheon-optimize.md +0 -79
- package/commands/pantheon-remember.md +0 -44
- package/commands/pantheon-search.md +0 -48
- package/commands/pantheon-status.md +0 -71
- package/commands/pantheon.md +0 -49
- package/scripts/check-version-consistency.sh +0 -23
- package/scripts/generate-routing-docs.mjs +0 -104
- package/scripts/manifest.mjs +0 -597
- package/scripts/memory_cache.py +0 -78
- package/scripts/paths.py +0 -64
- package/scripts/prune_context.py +0 -72
- package/scripts/release-bundle.mjs +0 -109
- package/scripts/todo_enforcer.py +0 -242
- package/scripts/validate_agent_frontmatter.py +0 -226
package/scripts/memory_cache.py
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""memory_cache.py — Migrate memory-bank flat files to MCP memory_store.
|
|
3
|
-
Usage: python3 scripts/memory_cache.py [--dry-run] [--path=<dir>]
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import argparse
|
|
7
|
-
import hashlib
|
|
8
|
-
import sys
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
EXCLUDE = {"_index.md", "_notes", ".tmp", "archive"}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def chunk_by_headings(content: str, source: str) -> list[dict]:
|
|
15
|
-
chunks = []
|
|
16
|
-
lines = content.split("\n")
|
|
17
|
-
current_h2 = "intro"
|
|
18
|
-
current_lines = []
|
|
19
|
-
for line in lines:
|
|
20
|
-
if line.startswith("## "):
|
|
21
|
-
if current_lines:
|
|
22
|
-
chunks.append(
|
|
23
|
-
{
|
|
24
|
-
"source": source,
|
|
25
|
-
"heading": current_h2,
|
|
26
|
-
"content": "\n".join(current_lines).strip(),
|
|
27
|
-
}
|
|
28
|
-
)
|
|
29
|
-
current_h2 = line.strip("# ")
|
|
30
|
-
current_lines = [line]
|
|
31
|
-
else:
|
|
32
|
-
current_lines.append(line)
|
|
33
|
-
if current_lines:
|
|
34
|
-
chunks.append(
|
|
35
|
-
{
|
|
36
|
-
"source": source,
|
|
37
|
-
"heading": current_h2,
|
|
38
|
-
"content": "\n".join(current_lines).strip(),
|
|
39
|
-
}
|
|
40
|
-
)
|
|
41
|
-
return chunks
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def main():
|
|
45
|
-
parser = argparse.ArgumentParser()
|
|
46
|
-
parser.add_argument("--dry-run", action="store_true", help="Preview only")
|
|
47
|
-
parser.add_argument("--path", default=".pantheon/memory-bank", help="Target dir")
|
|
48
|
-
args = parser.parse_args()
|
|
49
|
-
target = Path(args.path).resolve()
|
|
50
|
-
|
|
51
|
-
if not target.exists():
|
|
52
|
-
print(f"❌ Path not found: {target}")
|
|
53
|
-
sys.exit(1)
|
|
54
|
-
|
|
55
|
-
files = list(target.rglob("*.md"))
|
|
56
|
-
files = [f for f in files if not any(p in f.parts for p in EXCLUDE)]
|
|
57
|
-
|
|
58
|
-
total_chunks = 0
|
|
59
|
-
for f in files:
|
|
60
|
-
rel = f.relative_to(target)
|
|
61
|
-
content = f.read_text(encoding="utf-8", errors="ignore")
|
|
62
|
-
chunks = chunk_by_headings(content, str(rel))
|
|
63
|
-
total_chunks += len(chunks)
|
|
64
|
-
if args.dry_run:
|
|
65
|
-
print(f" 📄 {rel}: {len(chunks)} chunks")
|
|
66
|
-
else:
|
|
67
|
-
print(f" 📄 {rel}: {len(chunks)} chunks → memory_store()")
|
|
68
|
-
for c in chunks:
|
|
69
|
-
key = f"memory-cache:{rel}:{hashlib.md5(c['content'].encode()).hexdigest()[:8]}"
|
|
70
|
-
print(f" stored {key}")
|
|
71
|
-
|
|
72
|
-
print(
|
|
73
|
-
f"\n{'🔍 DRY RUN' if args.dry_run else '✅ DONE'}: {len(files)} files, {total_chunks} chunks"
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if __name__ == "__main__":
|
|
78
|
-
main()
|
package/scripts/paths.py
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Pantheon XDG Path Utilities — XDG Base Directory compliant path resolution.
|
|
3
|
-
|
|
4
|
-
Usage:
|
|
5
|
-
from paths import xdg_config_home, opencode_plans_dir
|
|
6
|
-
|
|
7
|
-
Per the XDG Base Directory spec (XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME, XDG_CACHE_HOME).
|
|
8
|
-
All fallback to their spec defaults when the env var is unset.
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
import os
|
|
12
|
-
from pathlib import Path
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _from_env(var: str, default: str) -> Path:
|
|
16
|
-
value = os.environ.get(var)
|
|
17
|
-
if value is None or not value.strip():
|
|
18
|
-
value = default
|
|
19
|
-
return Path(os.path.expanduser(value.strip()))
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def xdg_config_home() -> Path:
|
|
23
|
-
"""~/.config/ or $XDG_CONFIG_HOME"""
|
|
24
|
-
return _from_env("XDG_CONFIG_HOME", "~/.config")
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def xdg_data_home() -> Path:
|
|
28
|
-
"""~/.local/share/ or $XDG_DATA_HOME"""
|
|
29
|
-
return _from_env("XDG_DATA_HOME", "~/.local/share")
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def xdg_state_home() -> Path:
|
|
33
|
-
"""~/.local/state/ or $XDG_STATE_HOME"""
|
|
34
|
-
return _from_env("XDG_STATE_HOME", "~/.local/state")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def xdg_cache_home() -> Path:
|
|
38
|
-
"""~/.cache/ or $XDG_CACHE_HOME"""
|
|
39
|
-
return _from_env("XDG_CACHE_HOME", "~/.cache")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def opencode_config_dir() -> Path:
|
|
43
|
-
"""$XDG_CONFIG_HOME/opencode"""
|
|
44
|
-
return xdg_config_home() / "opencode"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def opencode_plans_dir() -> Path:
|
|
48
|
-
"""$XDG_CONFIG_HOME/opencode/platform/plans"""
|
|
49
|
-
return opencode_config_dir() / "platform" / "plans"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def opencode_data_dir() -> Path:
|
|
53
|
-
"""$XDG_DATA_HOME/opencode"""
|
|
54
|
-
return xdg_data_home() / "opencode"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def opencode_sync_repo_dir() -> Path:
|
|
58
|
-
"""$XDG_DATA_HOME/opencode/opencode-synced/repo"""
|
|
59
|
-
return opencode_data_dir() / "opencode-synced" / "repo"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def pantheon_log_dir() -> Path:
|
|
63
|
-
"""$XDG_STATE_HOME/pantheon/logs"""
|
|
64
|
-
return xdg_state_home() / "pantheon" / "logs"
|
package/scripts/prune_context.py
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""prune_context.py — Tool Output Pruning (Level 2 Compression v2).
|
|
3
|
-
Flags obsolete tool outputs in conversation history for pruning.
|
|
4
|
-
Usage: python3 scripts/prune_context.py [--dry-run] [--turns=5]
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import argparse
|
|
8
|
-
|
|
9
|
-
HIGH_AGE_TURNS = 10
|
|
10
|
-
MID_AGE_TURNS = 5
|
|
11
|
-
HIGH_LINES = 100
|
|
12
|
-
MID_LINES = 50
|
|
13
|
-
PRUNE_THRESHOLD = 0.5
|
|
14
|
-
LOW_RELEVANCE_TYPES = ("read", "grep", "glob")
|
|
15
|
-
HIGH_RELEVANCE_TYPES = ("test", "build")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def score_output(lines: int, age_turns: int, output_type: str) -> float:
|
|
19
|
-
"""Return relevance score 0.0-1.0. Lower = better to prune."""
|
|
20
|
-
score = 1.0
|
|
21
|
-
if age_turns > HIGH_AGE_TURNS:
|
|
22
|
-
score -= 0.3
|
|
23
|
-
elif age_turns > MID_AGE_TURNS:
|
|
24
|
-
score -= 0.15
|
|
25
|
-
if lines > HIGH_LINES:
|
|
26
|
-
score -= 0.2
|
|
27
|
-
elif lines > MID_LINES:
|
|
28
|
-
score -= 0.1
|
|
29
|
-
if output_type in LOW_RELEVANCE_TYPES:
|
|
30
|
-
score -= 0.1
|
|
31
|
-
if output_type in HIGH_RELEVANCE_TYPES:
|
|
32
|
-
score += 0.1
|
|
33
|
-
return max(0.0, min(1.0, score))
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def main():
|
|
37
|
-
parser = argparse.ArgumentParser()
|
|
38
|
-
parser.add_argument("--dry-run", action="store_true")
|
|
39
|
-
parser.add_argument("--turns", type=int, default=5, help="Min age in turns")
|
|
40
|
-
args = parser.parse_args()
|
|
41
|
-
|
|
42
|
-
candidates = [
|
|
43
|
-
{"file": "src/main.py", "lines": 120, "age": 7, "type": "read"},
|
|
44
|
-
{"file": "tests/test_auth.py", "lines": 45, "age": 3, "type": "test"},
|
|
45
|
-
{"file": "package.json", "lines": 60, "age": 12, "type": "read"},
|
|
46
|
-
]
|
|
47
|
-
|
|
48
|
-
print(f"🔍 Context Pruning Scan (age threshold: {args.turns}+ turns)")
|
|
49
|
-
print("━" * 50)
|
|
50
|
-
prunable = []
|
|
51
|
-
for c in candidates:
|
|
52
|
-
s = score_output(c["lines"], c["age"], c["type"])
|
|
53
|
-
status = "🗑️ PRUNE" if s < PRUNE_THRESHOLD else "✅ KEEP"
|
|
54
|
-
if s < PRUNE_THRESHOLD:
|
|
55
|
-
prunable.append(c)
|
|
56
|
-
print(
|
|
57
|
-
f" {status} | score: {s:.2f} | {c['file']} ({c['lines']}L, {c['age']}turns, {c['type']})"
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
print("━" * 50)
|
|
61
|
-
if args.dry_run:
|
|
62
|
-
print(
|
|
63
|
-
f"🔍 DRY RUN: {len(prunable)} candidates to prune (estimated ~{sum(c['lines'] for c in prunable)} lines)"
|
|
64
|
-
)
|
|
65
|
-
else:
|
|
66
|
-
print(
|
|
67
|
-
f"✅ {len(prunable)} outputs pruned (freed ~{sum(c['lines'] for c in prunable)} lines)"
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if __name__ == "__main__":
|
|
72
|
-
main()
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* release-bundle.mjs — Creates the distribution tar.gz for a Pantheon release.
|
|
5
|
-
*
|
|
6
|
-
* Packages all user-relevant files into pantheon-vX.Y.Z.tar.gz.
|
|
7
|
-
* Excludes build tooling (scripts/, .github/, node_modules/).
|
|
8
|
-
*
|
|
9
|
-
* Usage:
|
|
10
|
-
* node scripts/release-bundle.mjs # reads version from package.json
|
|
11
|
-
* node scripts/release-bundle.mjs --output ./dist # custom output dir
|
|
12
|
-
* node scripts/release-bundle.mjs --version 3.5.0 # explicit version
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { execSync } from 'node:child_process'
|
|
16
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
|
-
import { dirname, join } from 'node:path'
|
|
18
|
-
import { fileURLToPath } from 'node:url'
|
|
19
|
-
|
|
20
|
-
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
21
|
-
const ROOT = join(__dirname, '..')
|
|
22
|
-
|
|
23
|
-
// ── Parse args ────────────────────────────────────────────────────────────────
|
|
24
|
-
const args = process.argv.slice(2)
|
|
25
|
-
function arg(flag) {
|
|
26
|
-
const i = args.indexOf(flag)
|
|
27
|
-
return i !== -1 ? args[i + 1] : null
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const outputDir = arg('--output') ?? join(ROOT, 'dist')
|
|
31
|
-
const version =
|
|
32
|
-
arg('--version') ?? JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version
|
|
33
|
-
|
|
34
|
-
const bundleName = `pantheon-v${version}`
|
|
35
|
-
const stagingDir = join(ROOT, 'dist', `.staging-${bundleName}`)
|
|
36
|
-
const outputTar = join(outputDir, `${bundleName}.tar.gz`)
|
|
37
|
-
|
|
38
|
-
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
39
|
-
function copy(src, dest) {
|
|
40
|
-
const srcPath = join(ROOT, src)
|
|
41
|
-
if (!existsSync(srcPath)) {
|
|
42
|
-
console.log(` ⏭ skipped ${src} (not found)`)
|
|
43
|
-
return
|
|
44
|
-
}
|
|
45
|
-
const destPath = join(stagingDir, bundleName, dest ?? src)
|
|
46
|
-
mkdirSync(dirname(destPath), { recursive: true })
|
|
47
|
-
cpSync(srcPath, destPath, { recursive: true })
|
|
48
|
-
console.log(` ✓ ${src}`)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ── Stage files ───────────────────────────────────────────────────────────────
|
|
52
|
-
console.log(`\nBuilding bundle: ${bundleName}`)
|
|
53
|
-
|
|
54
|
-
if (existsSync(stagingDir)) rmSync(stagingDir, { recursive: true, force: true })
|
|
55
|
-
mkdirSync(join(stagingDir, bundleName), { recursive: true })
|
|
56
|
-
|
|
57
|
-
// Root documents
|
|
58
|
-
copy('README.md')
|
|
59
|
-
copy('AGENTS.md')
|
|
60
|
-
copy('CHANGELOG.md')
|
|
61
|
-
copy('CONTRIBUTING.md')
|
|
62
|
-
copy('LICENSE')
|
|
63
|
-
|
|
64
|
-
// Config
|
|
65
|
-
copy('opencode.json')
|
|
66
|
-
copy('plugin.json')
|
|
67
|
-
|
|
68
|
-
// Shell helper
|
|
69
|
-
copy('sync-opencode.sh')
|
|
70
|
-
|
|
71
|
-
// Entry point exposed at root
|
|
72
|
-
copy('bin/pantheon-init.mjs', 'pantheon-init.mjs')
|
|
73
|
-
|
|
74
|
-
// Agent definitions
|
|
75
|
-
copy('agents')
|
|
76
|
-
copy('skills')
|
|
77
|
-
copy('prompts')
|
|
78
|
-
copy('platform')
|
|
79
|
-
copy('instructions')
|
|
80
|
-
copy('docs')
|
|
81
|
-
copy('images')
|
|
82
|
-
|
|
83
|
-
// Platform MCP configs
|
|
84
|
-
copy('.vscode')
|
|
85
|
-
copy('.cursor')
|
|
86
|
-
copy('.claude')
|
|
87
|
-
copy('platform/mcp/mcp-template.json')
|
|
88
|
-
|
|
89
|
-
// ── Write a minimal package.json so install.mjs can resolve it ────────────────
|
|
90
|
-
const minPkg = {
|
|
91
|
-
name: 'pantheon',
|
|
92
|
-
version,
|
|
93
|
-
description: 'Multi-agent orchestration platform for AI coding assistants',
|
|
94
|
-
type: 'module',
|
|
95
|
-
}
|
|
96
|
-
writeFileSync(join(stagingDir, bundleName, 'package.json'), `${JSON.stringify(minPkg, null, 2)}\n`)
|
|
97
|
-
|
|
98
|
-
// ── Create tar.gz ─────────────────────────────────────────────────────────────
|
|
99
|
-
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true })
|
|
100
|
-
|
|
101
|
-
execSync(`tar czf "${outputTar}" -C "${stagingDir}" "${bundleName}"`, { stdio: 'inherit' })
|
|
102
|
-
|
|
103
|
-
// ── Cleanup staging ───────────────────────────────────────────────────────────
|
|
104
|
-
rmSync(stagingDir, { recursive: true, force: true })
|
|
105
|
-
|
|
106
|
-
// ── Report ────────────────────────────────────────────────────────────────────
|
|
107
|
-
const { size } = { size: execSync(`wc -c < "${outputTar}"`).toString().trim() }
|
|
108
|
-
const kb = Math.round(Number(size) / 1024)
|
|
109
|
-
console.log(`\n✅ ${outputTar} (${kb} KB)`)
|
package/scripts/todo_enforcer.py
DELETED
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""TODO Enforcer — Idle detection and recovery for Pantheon agents.
|
|
3
|
-
|
|
4
|
-
Monitors agent tool-call activity and enforces timeouts:
|
|
5
|
-
- Idle > 60s → log warning
|
|
6
|
-
- Idle > 120s → force continue with task prompt
|
|
7
|
-
- Idle > 300s → auto-save checkpoint and pause
|
|
8
|
-
|
|
9
|
-
Usage:
|
|
10
|
-
python3 scripts/todo_enforcer.py <task> [--timeout-warn=60] [--timeout-force=120] [--timeout-pause=300]
|
|
11
|
-
python3 scripts/todo_enforcer.py --status # Show current enforcer state
|
|
12
|
-
python3 scripts/todo_enforcer.py --reset # Reset idle timer
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
import argparse
|
|
16
|
-
import json
|
|
17
|
-
import sys
|
|
18
|
-
import time
|
|
19
|
-
from pathlib import Path
|
|
20
|
-
|
|
21
|
-
ENFORCER_DIR = Path(".pantheon/todo-enforcer")
|
|
22
|
-
STATE_FILE = ENFORCER_DIR / "state.json"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class TodoEnforcer:
|
|
26
|
-
"""Stateful idle monitor for agent sessions."""
|
|
27
|
-
|
|
28
|
-
def __init__(
|
|
29
|
-
self,
|
|
30
|
-
task: str,
|
|
31
|
-
timeout_warn: int = 60,
|
|
32
|
-
timeout_force: int = 120,
|
|
33
|
-
timeout_pause: int = 300,
|
|
34
|
-
max_force_continues: int = 3,
|
|
35
|
-
):
|
|
36
|
-
self.task = task
|
|
37
|
-
self.timeout_warn = timeout_warn
|
|
38
|
-
self.timeout_force = timeout_force
|
|
39
|
-
self.timeout_pause = timeout_pause
|
|
40
|
-
self.max_force_continues = max_force_continues
|
|
41
|
-
self._ensure_dir()
|
|
42
|
-
|
|
43
|
-
# ------------------------------------------------------------------
|
|
44
|
-
# Public API
|
|
45
|
-
# ------------------------------------------------------------------
|
|
46
|
-
|
|
47
|
-
def tick(self) -> dict:
|
|
48
|
-
"""Record a tool-call heartbeat (resets idle timer)."""
|
|
49
|
-
state = self._load()
|
|
50
|
-
state["last_tool_call"] = time.time()
|
|
51
|
-
state["task"] = self.task
|
|
52
|
-
state["warnings_sent"] = state.get("warnings_sent", [])
|
|
53
|
-
state["forced_continues"] = state.get("forced_continues", 0)
|
|
54
|
-
state["paused"] = False
|
|
55
|
-
self._save(state)
|
|
56
|
-
return {"status": "ok", "idle": 0.0}
|
|
57
|
-
|
|
58
|
-
def check(self) -> dict:
|
|
59
|
-
"""Check idle duration and return action if needed."""
|
|
60
|
-
state = self._load()
|
|
61
|
-
if state.get("paused"):
|
|
62
|
-
return {"status": "paused", "action": None}
|
|
63
|
-
|
|
64
|
-
last_call = state.get("last_tool_call")
|
|
65
|
-
if last_call is None:
|
|
66
|
-
return {"status": "no_data", "action": None}
|
|
67
|
-
|
|
68
|
-
idle = time.time() - last_call
|
|
69
|
-
action = None
|
|
70
|
-
|
|
71
|
-
if idle >= self.timeout_pause:
|
|
72
|
-
action = self._build_action("pause", idle)
|
|
73
|
-
state["paused"] = True
|
|
74
|
-
state["stop_reason"] = "timeout_pause"
|
|
75
|
-
elif idle >= self.timeout_force:
|
|
76
|
-
fc_count = state.get("forced_continues", 0)
|
|
77
|
-
if fc_count >= self.max_force_continues:
|
|
78
|
-
action = self._build_action("pause", idle)
|
|
79
|
-
state["paused"] = True
|
|
80
|
-
state["stop_reason"] = "max_force_continues"
|
|
81
|
-
else:
|
|
82
|
-
action = self._build_action("force_continue", idle)
|
|
83
|
-
state["forced_continues"] = fc_count + 1
|
|
84
|
-
elif idle >= self.timeout_warn:
|
|
85
|
-
action = self._build_action("warn", idle)
|
|
86
|
-
if "warn" not in state.get("warnings_sent", []):
|
|
87
|
-
state.setdefault("warnings_sent", []).append("warn")
|
|
88
|
-
|
|
89
|
-
self._save(state)
|
|
90
|
-
return {
|
|
91
|
-
"status": "idle" if idle > 0 else "active",
|
|
92
|
-
"idle": idle,
|
|
93
|
-
"action": action,
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
def reset(self) -> dict:
|
|
97
|
-
"""Reset idle timer and unpause."""
|
|
98
|
-
state = self._load()
|
|
99
|
-
state["last_tool_call"] = time.time()
|
|
100
|
-
state["paused"] = False
|
|
101
|
-
state["warnings_sent"] = []
|
|
102
|
-
state["forced_continues"] = 0
|
|
103
|
-
state["stop_reason"] = None
|
|
104
|
-
self._save(state)
|
|
105
|
-
return {"status": "reset", "idle": 0.0}
|
|
106
|
-
|
|
107
|
-
def status(self) -> dict:
|
|
108
|
-
"""Return current state without side effects."""
|
|
109
|
-
state = self._load()
|
|
110
|
-
idle = time.time() - state.get("last_tool_call", time.time())
|
|
111
|
-
return {
|
|
112
|
-
"task": state.get("task", self.task),
|
|
113
|
-
"idle": round(idle, 1),
|
|
114
|
-
"paused": state.get("paused", False),
|
|
115
|
-
"forced_continues": state.get("forced_continues", 0),
|
|
116
|
-
"stop_reason": state.get("stop_reason"),
|
|
117
|
-
"timeout_warn": self.timeout_warn,
|
|
118
|
-
"timeout_force": self.timeout_force,
|
|
119
|
-
"timeout_pause": self.timeout_pause,
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
def pause(self) -> dict:
|
|
123
|
-
"""Pause the enforcer (mark session stopped)."""
|
|
124
|
-
state = self._load()
|
|
125
|
-
state["paused"] = True
|
|
126
|
-
state["stop_reason"] = "manual_pause"
|
|
127
|
-
self._save(state)
|
|
128
|
-
return {"status": "paused"}
|
|
129
|
-
|
|
130
|
-
# ------------------------------------------------------------------
|
|
131
|
-
# Internal helpers
|
|
132
|
-
# ------------------------------------------------------------------
|
|
133
|
-
|
|
134
|
-
def _build_action(self, level: str, idle: float) -> dict:
|
|
135
|
-
prompts = {
|
|
136
|
-
"warn": f"[TODO ENFORCER] Agent idle for {idle:.0f}s. Continuing task: {self.task}",
|
|
137
|
-
"force_continue": f"[TODO ENFORCER] FORCE CONTINUE — Agent idle for {idle:.0f}s. Resume work on: {self.task}",
|
|
138
|
-
"pause": f"[TODO ENFORCER] AUTO-PAUSE — Agent idle for {idle:.0f}s. Session paused due to idle timeout.",
|
|
139
|
-
}
|
|
140
|
-
return {
|
|
141
|
-
"level": level,
|
|
142
|
-
"idle": round(idle, 1),
|
|
143
|
-
"message": prompts[level],
|
|
144
|
-
"checkpoint": level == "pause",
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
def _ensure_dir(self) -> None:
|
|
148
|
-
ENFORCER_DIR.mkdir(parents=True, exist_ok=True)
|
|
149
|
-
|
|
150
|
-
def _load(self) -> dict:
|
|
151
|
-
if STATE_FILE.exists():
|
|
152
|
-
try:
|
|
153
|
-
return json.loads(STATE_FILE.read_text())
|
|
154
|
-
except (json.JSONDecodeError, OSError):
|
|
155
|
-
pass
|
|
156
|
-
return {
|
|
157
|
-
"task": self.task,
|
|
158
|
-
"last_tool_call": time.time(),
|
|
159
|
-
"paused": False,
|
|
160
|
-
"warnings_sent": [],
|
|
161
|
-
"forced_continues": 0,
|
|
162
|
-
"stop_reason": None,
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
def _save(self, state: dict) -> None:
|
|
166
|
-
STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False))
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def main() -> None:
|
|
170
|
-
parser = argparse.ArgumentParser(
|
|
171
|
-
description="TODO Enforcer — idle detection and recovery for Pantheon agents."
|
|
172
|
-
)
|
|
173
|
-
parser.add_argument(
|
|
174
|
-
"task", nargs="?", default="", help="Task description for force-continue prompt"
|
|
175
|
-
)
|
|
176
|
-
parser.add_argument(
|
|
177
|
-
"--timeout-warn",
|
|
178
|
-
type=int,
|
|
179
|
-
default=60,
|
|
180
|
-
help="Idle seconds before warning (default: 60)",
|
|
181
|
-
)
|
|
182
|
-
parser.add_argument(
|
|
183
|
-
"--timeout-force",
|
|
184
|
-
type=int,
|
|
185
|
-
default=120,
|
|
186
|
-
help="Idle seconds before force continue (default: 120)",
|
|
187
|
-
)
|
|
188
|
-
parser.add_argument(
|
|
189
|
-
"--timeout-pause",
|
|
190
|
-
type=int,
|
|
191
|
-
default=300,
|
|
192
|
-
help="Idle seconds before auto-pause (default: 300)",
|
|
193
|
-
)
|
|
194
|
-
parser.add_argument(
|
|
195
|
-
"--status", action="store_true", help="Show current enforcer state"
|
|
196
|
-
)
|
|
197
|
-
parser.add_argument("--reset", action="store_true", help="Reset idle timer")
|
|
198
|
-
parser.add_argument(
|
|
199
|
-
"--tick", action="store_true", help="Record a heartbeat (tool call)"
|
|
200
|
-
)
|
|
201
|
-
parser.add_argument(
|
|
202
|
-
"--check", action="store_true", help="Check idle status and return action"
|
|
203
|
-
)
|
|
204
|
-
parser.add_argument("--pause", action="store_true", help="Pause the enforcer")
|
|
205
|
-
args = parser.parse_args()
|
|
206
|
-
|
|
207
|
-
enforcer = TodoEnforcer(
|
|
208
|
-
task=args.task or "unknown",
|
|
209
|
-
timeout_warn=args.timeout_warn,
|
|
210
|
-
timeout_force=args.timeout_force,
|
|
211
|
-
timeout_pause=args.timeout_pause,
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
if args.status:
|
|
215
|
-
result = enforcer.status()
|
|
216
|
-
elif args.reset:
|
|
217
|
-
result = enforcer.reset()
|
|
218
|
-
elif args.tick:
|
|
219
|
-
result = enforcer.tick()
|
|
220
|
-
elif args.check:
|
|
221
|
-
result = enforcer.check()
|
|
222
|
-
elif args.pause:
|
|
223
|
-
result = enforcer.pause()
|
|
224
|
-
else:
|
|
225
|
-
result = enforcer.check()
|
|
226
|
-
|
|
227
|
-
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
228
|
-
|
|
229
|
-
# Exit codes: 0=normal, 1=warn, 2=force, 3=pause
|
|
230
|
-
if isinstance(result, dict) and result.get("action"):
|
|
231
|
-
level = result["action"].get("level")
|
|
232
|
-
if level == "pause":
|
|
233
|
-
sys.exit(3)
|
|
234
|
-
elif level == "force_continue":
|
|
235
|
-
sys.exit(2)
|
|
236
|
-
elif level == "warn":
|
|
237
|
-
sys.exit(1)
|
|
238
|
-
sys.exit(0)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if __name__ == "__main__":
|
|
242
|
-
main()
|