@kitelev/exocortex-cli 15.162.0 → 15.164.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ai-task-runner +39 -0
- package/bin/ai-task-worker +127 -0
- package/dist/index.js +149 -149
- package/package.json +4 -1
- package/scripts/postinstall.cjs +28 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ai-task-runner — executed inside spawned tmux session per claimed task.
|
|
3
|
+
# Phase 6: real claude -p --dangerously-skip-permissions invocation.
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
TASK_FILE="${1:?task file required}"
|
|
8
|
+
SESSION_LOG="${2:?session log required}"
|
|
9
|
+
|
|
10
|
+
mkdir -p "$(dirname "$SESSION_LOG")"
|
|
11
|
+
exec >>"$SESSION_LOG" 2>&1
|
|
12
|
+
|
|
13
|
+
echo "[runner] $(date '+%Y-%m-%dT%H:%M:%S%z'): start task=$TASK_FILE"
|
|
14
|
+
|
|
15
|
+
# Extract metadata from task frontmatter
|
|
16
|
+
eval "$(/usr/bin/python3 - "$TASK_FILE" <<'PYEOF'
|
|
17
|
+
import sys, re
|
|
18
|
+
fp = sys.argv[1]
|
|
19
|
+
content = open(fp, encoding='utf-8').read()
|
|
20
|
+
end = content.find('\n---', 3)
|
|
21
|
+
fm = content[3:end]
|
|
22
|
+
def get(key):
|
|
23
|
+
m = re.search(rf'^{re.escape(key)}\s*:\s*(.*)$', fm, re.MULTILINE)
|
|
24
|
+
return m.group(1).strip().strip('"') if m else ''
|
|
25
|
+
model = get('aiTask__Task_model') or 'sonnet'
|
|
26
|
+
timeout = get('aiTask__Task_timeoutMinutes') or '30'
|
|
27
|
+
print(f"TASK_MODEL={model}")
|
|
28
|
+
print(f"TASK_TIMEOUT={timeout}")
|
|
29
|
+
PYEOF
|
|
30
|
+
)"
|
|
31
|
+
|
|
32
|
+
# Build prompt: full task file content (frontmatter + body)
|
|
33
|
+
PROMPT="$(< "$TASK_FILE")"
|
|
34
|
+
|
|
35
|
+
echo "[runner] $(date '+%Y-%m-%dT%H:%M:%S%z'): spawning claude model=$TASK_MODEL timeout=${TASK_TIMEOUT}m"
|
|
36
|
+
|
|
37
|
+
# Run real claude -p — exec replaces this process
|
|
38
|
+
exec env -u CLAUDECODE -u CLAUDE_CODE_ENTRYPOINT -u CLAUDE_CODE_EXECPATH \
|
|
39
|
+
claude --dangerously-skip-permissions --model "$TASK_MODEL" -p "$PROMPT"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ai-task-worker — scan vault for delegated aiTask, claim atomically, spawn tmux session.
|
|
3
|
+
#
|
|
4
|
+
# Picks tasks where ems__Effort_status=Backlog AND aiTask__Task_delegated="true" AND no claimedBy.
|
|
5
|
+
# Atomic claim via frontmatter update (writes Doing+claimedBy+claimedAt+sessionLog).
|
|
6
|
+
# Spawns a detached tmux session named claude-child-<uuid> executing ai-task-runner,
|
|
7
|
+
# which calls real claude -p --dangerously-skip-permissions with task content as prompt.
|
|
8
|
+
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
|
|
11
|
+
VAULT_DIR="${EXOCORTEX_VAULT:-/Users/kitelev/vault-2025}"
|
|
12
|
+
LOG_DIR="$HOME/.exocortex/logs"
|
|
13
|
+
mkdir -p "$LOG_DIR"
|
|
14
|
+
LOG="$LOG_DIR/aitask-worker.log"
|
|
15
|
+
RUNNER="$HOME/.exocortex/bin/ai-task-runner"
|
|
16
|
+
|
|
17
|
+
log() { echo "[ai-task-worker] $(date '+%Y-%m-%dT%H:%M:%S%z'): $*" | tee -a "$LOG"; }
|
|
18
|
+
|
|
19
|
+
log "Scan start (vault=$VAULT_DIR)"
|
|
20
|
+
|
|
21
|
+
/usr/bin/python3 - "$VAULT_DIR" "$LOG" "$RUNNER" "$$" <<'PYEOF'
|
|
22
|
+
import os, re, sys, datetime, tempfile, shutil, subprocess
|
|
23
|
+
|
|
24
|
+
vault, log_path, runner, parent_pid = sys.argv[1:5]
|
|
25
|
+
|
|
26
|
+
def log(msg):
|
|
27
|
+
ts = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S%z')
|
|
28
|
+
line = f"[ai-task-worker] {ts}: {msg}"
|
|
29
|
+
print(line)
|
|
30
|
+
open(log_path, 'a').write(line + "\n")
|
|
31
|
+
|
|
32
|
+
def now_iso5():
|
|
33
|
+
tz5 = datetime.timezone(datetime.timedelta(hours=5))
|
|
34
|
+
return datetime.datetime.now(tz5).strftime('%Y-%m-%dT%H:%M:%S+0500')
|
|
35
|
+
|
|
36
|
+
def split_fm(text):
|
|
37
|
+
if not text.startswith('---'):
|
|
38
|
+
return None, text
|
|
39
|
+
end = text.find('\n---', 3)
|
|
40
|
+
if end == -1:
|
|
41
|
+
return None, text
|
|
42
|
+
return text[3:end], text[end+4:]
|
|
43
|
+
|
|
44
|
+
def set_field(fm, key, val):
|
|
45
|
+
pat = re.compile(rf'^({re.escape(key)})\s*:.*$', re.MULTILINE)
|
|
46
|
+
if pat.search(fm):
|
|
47
|
+
return pat.sub(f'{key}: {val}', fm, 1)
|
|
48
|
+
return fm.rstrip('\n') + f'\n{key}: {val}\n'
|
|
49
|
+
|
|
50
|
+
def get_field(fm, key):
|
|
51
|
+
m = re.search(rf'^{re.escape(key)}\s*:\s*(.*)$', fm, re.MULTILINE)
|
|
52
|
+
return m.group(1).strip() if m else ''
|
|
53
|
+
|
|
54
|
+
def write_atomic(path, fm, body):
|
|
55
|
+
d = os.path.dirname(path)
|
|
56
|
+
with tempfile.NamedTemporaryFile('w', dir=d, delete=False, suffix='.tmp', encoding='utf-8') as t:
|
|
57
|
+
t.write(f"---{fm}\n---{body}")
|
|
58
|
+
tmp = t.name
|
|
59
|
+
shutil.move(tmp, path)
|
|
60
|
+
|
|
61
|
+
DELEG_PAT = re.compile(r'aiTask__Task_delegated\s*:\s*"?true"?', re.IGNORECASE)
|
|
62
|
+
BACKLOG_PAT = re.compile(r'ems__Effort_status\s*:\s*["\[]*ems__EffortStatusBacklog')
|
|
63
|
+
CLAIMED_PAT = re.compile(r'^aiTask__Task_claimedBy\s*:', re.MULTILINE)
|
|
64
|
+
|
|
65
|
+
candidates = []
|
|
66
|
+
for dp, dns, fns in os.walk(vault):
|
|
67
|
+
dns[:] = [d for d in dns if not d.startswith('.')]
|
|
68
|
+
for fn in fns:
|
|
69
|
+
if not fn.endswith('.md'):
|
|
70
|
+
continue
|
|
71
|
+
fp = os.path.join(dp, fn)
|
|
72
|
+
try:
|
|
73
|
+
content = open(fp, encoding='utf-8').read()
|
|
74
|
+
except Exception:
|
|
75
|
+
continue
|
|
76
|
+
if not content.startswith('---'):
|
|
77
|
+
continue
|
|
78
|
+
fm, _ = split_fm(content)
|
|
79
|
+
if fm is None:
|
|
80
|
+
continue
|
|
81
|
+
if not DELEG_PAT.search(fm):
|
|
82
|
+
continue
|
|
83
|
+
if not BACKLOG_PAT.search(fm):
|
|
84
|
+
continue
|
|
85
|
+
if CLAIMED_PAT.search(fm):
|
|
86
|
+
continue
|
|
87
|
+
candidates.append(fp)
|
|
88
|
+
|
|
89
|
+
log(f"Candidates: {len(candidates)}")
|
|
90
|
+
|
|
91
|
+
for fp in candidates:
|
|
92
|
+
try:
|
|
93
|
+
content = open(fp, encoding='utf-8').read()
|
|
94
|
+
fm, body = split_fm(content)
|
|
95
|
+
if fm is None:
|
|
96
|
+
continue
|
|
97
|
+
uid = get_field(fm, 'exo__Asset_uid').strip('"')
|
|
98
|
+
label = get_field(fm, 'exo__Asset_label').strip('"')
|
|
99
|
+
session_name = f"claude-child-{uid}"
|
|
100
|
+
session_log = f"{os.path.expanduser('~')}/.exocortex/logs/aitask-session-{uid}.log"
|
|
101
|
+
ts = now_iso5()
|
|
102
|
+
|
|
103
|
+
# Atomic claim — set Doing + claimedBy + claimedAt + sessionLog + startTimestamp + updatedAt
|
|
104
|
+
new_fm = fm
|
|
105
|
+
new_fm = set_field(new_fm, 'ems__Effort_status', '"[[ems__EffortStatusDoing]]"')
|
|
106
|
+
new_fm = set_field(new_fm, 'aiTask__Task_claimedBy', f'"{parent_pid}"')
|
|
107
|
+
new_fm = set_field(new_fm, 'aiTask__Task_claimedAt', f'"{ts}"')
|
|
108
|
+
new_fm = set_field(new_fm, 'aiTask__Task_sessionLog', f'"{session_log}"')
|
|
109
|
+
if 'ems__Effort_startTimestamp' not in new_fm:
|
|
110
|
+
new_fm = set_field(new_fm, 'ems__Effort_startTimestamp', ts)
|
|
111
|
+
new_fm = set_field(new_fm, 'exo__Asset_updatedAt', ts)
|
|
112
|
+
write_atomic(fp, new_fm, body)
|
|
113
|
+
log(f"CLAIMED {uid} ({label}) → session={session_name}")
|
|
114
|
+
|
|
115
|
+
# Spawn detached tmux session named claude-child-<full-uuid>
|
|
116
|
+
cmd = ['tmux', 'new-session', '-d', '-s', session_name,
|
|
117
|
+
'bash', '-l', '-c', f"{runner} '{fp}' '{session_log}' 2>&1 | tee -a '{session_log}'"]
|
|
118
|
+
try:
|
|
119
|
+
subprocess.run(cmd, check=True)
|
|
120
|
+
log(f"SPAWNED tmux session {session_name}")
|
|
121
|
+
except subprocess.CalledProcessError as e:
|
|
122
|
+
log(f"SPAWN_FAIL {uid}: {e}")
|
|
123
|
+
except Exception as e:
|
|
124
|
+
log(f"ERROR {fp}: {e}")
|
|
125
|
+
|
|
126
|
+
log("Scan done")
|
|
127
|
+
PYEOF
|