instar 1.2.54 → 1.2.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.claude/skills/autonomous/hooks/autonomous-stop-hook.sh +279 -105
  2. package/.claude/skills/autonomous/scripts/setup-autonomous.sh +49 -2
  3. package/dist/core/AutonomousSessions.d.ts +66 -0
  4. package/dist/core/AutonomousSessions.d.ts.map +1 -0
  5. package/dist/core/AutonomousSessions.js +161 -0
  6. package/dist/core/AutonomousSessions.js.map +1 -0
  7. package/dist/core/PostUpdateMigrator.d.ts +18 -0
  8. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  9. package/dist/core/PostUpdateMigrator.js +69 -0
  10. package/dist/core/PostUpdateMigrator.js.map +1 -1
  11. package/dist/core/types.d.ts +5 -0
  12. package/dist/core/types.d.ts.map +1 -1
  13. package/dist/core/types.js.map +1 -1
  14. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  15. package/dist/messaging/TelegramAdapter.js +9 -0
  16. package/dist/messaging/TelegramAdapter.js.map +1 -1
  17. package/dist/scaffold/templates.d.ts.map +1 -1
  18. package/dist/scaffold/templates.js +7 -0
  19. package/dist/scaffold/templates.js.map +1 -1
  20. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  21. package/dist/server/CapabilityIndex.js +21 -0
  22. package/dist/server/CapabilityIndex.js.map +1 -1
  23. package/dist/server/routes.d.ts.map +1 -1
  24. package/dist/server/routes.js +29 -0
  25. package/dist/server/routes.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/data/builtin-manifest.json +61 -61
  28. package/src/scaffold/templates.ts +7 -0
  29. package/upgrades/1.2.55.md +106 -0
  30. package/upgrades/1.2.56.md +71 -0
  31. package/upgrades/side-effects/autonomous-topic-keyed-identity.md +105 -0
  32. package/upgrades/side-effects/multi-session-autonomy.md +102 -0
@@ -4,167 +4,348 @@
4
4
  # Prevents session exit when autonomous mode is active.
5
5
  # Feeds the goal and task list back to continue working.
6
6
  #
7
- # SESSION-SCOPED: Only blocks the session that activated autonomous mode.
8
- # Other sessions on the same machine pass through unaffected.
7
+ # MULTI-SESSION (per-topic state): each autonomous job has its own state file at
8
+ # .instar/autonomous/<topicId>.local.md, so multiple topics can run autonomous
9
+ # jobs concurrently without colliding. This session resolves its own topic (from
10
+ # its tmux session name via the topic-session registry) and reads THAT topic's
11
+ # state file. Ownership is therefore implicit: if my topic's file exists and is
12
+ # active, I am its worker. A legacy single-file job (.instar/autonomous-state.local.md)
13
+ # is still honored for back-compat and migrated to the per-topic path on first touch.
9
14
  #
10
- # RESPECTS:
11
- # - Session isolation (only blocks the autonomous session)
12
- # - Emergency stop signals (user says "stop everything")
13
- # - Duration expiry
14
- # - Genuine completion (all tasks done, promise output)
15
+ # TOPIC-KEYED OWNERSHIP survives restarts: a memory-limit restart rotates the
16
+ # Claude session UUID but instar respawns into the SAME tmux name, which still
17
+ # maps to the same topic so the restarted session reads the same per-topic file
18
+ # and keeps going. (Legacy-file path keeps the v1.2.55 topic-or-liveness backstop.)
19
+ #
20
+ # RECOVERY NOTE: on a real restart-and-resume (topic file found but the recorded
21
+ # session UUID changed) the hook emits ONE channel-neutral recovery note + audit
22
+ # record, then records the new UUID so it never repeats.
23
+ #
24
+ # RESPECTS: emergency stop, duration expiry, genuine completion (promise).
15
25
 
16
- set -euo pipefail
26
+ set -uo pipefail # NOTE: -e intentionally omitted; field lookups for optional
27
+ # frontmatter keys are expected to "fail" (grep finds nothing)
28
+ # and must not abort the hook. Each critical step guards itself.
17
29
 
18
30
  # Read hook input from stdin
19
31
  HOOK_INPUT=$(cat)
20
32
 
21
- # Check if autonomous mode is active
22
- STATE_FILE=".instar/autonomous-state.local.md"
33
+ REGISTRY_FILE=".instar/topic-session-registry.json"
34
+ RECOVERY_AUDIT=".instar/autonomous-recovery.jsonl"
35
+ LEGACY_STATE=".instar/autonomous-state.local.md"
36
+ MULTI_DIR=".instar/autonomous"
37
+ LIVENESS_SECS="${INSTAR_AUTONOMOUS_LIVENESS_SECS:-120}"
38
+
39
+ # ── Inputs from the hook ──────────────────────────────────────────────
40
+ HOOK_SESSION=$(printf '%s' "$HOOK_INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
41
+ TRANSCRIPT_PATH=$(printf '%s' "$HOOK_INPUT" | jq -r '.transcript_path // ""' 2>/dev/null || echo "")
42
+
43
+ # If hook has no session_id → fail OPEN (unknown context, don't trap)
44
+ if [[ -z "$HOOK_SESSION" ]]; then
45
+ echo "⚠️ Autonomous mode: No session_id in hook input — fail-open (allowing exit)" >&2
46
+ exit 0
47
+ fi
48
+
49
+ # ── Resolve MY tmux session name (the stable address) ─────────────────
50
+ # Test/override seam: INSTAR_HOOK_TMUX_SESSION (if the var is set at all, even
51
+ # empty, it wins — empty means "no tmux"). INSTAR_HOOK_NO_TMUX=1 forces empty.
52
+ resolve_my_tmux() {
53
+ if [[ "${INSTAR_HOOK_NO_TMUX:-}" == "1" ]]; then
54
+ echo ""
55
+ return
56
+ fi
57
+ if [[ -n "${INSTAR_HOOK_TMUX_SESSION+x}" ]]; then
58
+ echo "${INSTAR_HOOK_TMUX_SESSION}"
59
+ return
60
+ fi
61
+ tmux display-message -p '#S' 2>/dev/null || echo ""
62
+ }
63
+ MY_TMUX=$(resolve_my_tmux)
64
+
65
+ # Reverse-lookup: which topic does MY tmux session serve? (topicToSession is
66
+ # topic→tmux; we invert it to find my topic.)
67
+ MY_TOPIC=""
68
+ if [[ -n "$MY_TMUX" ]] && [[ -f "$REGISTRY_FILE" ]]; then
69
+ MY_TOPIC=$(INSTAR_MY_TMUX="$MY_TMUX" python3 -c "
70
+ import json, os
71
+ try:
72
+ reg = json.load(open('$REGISTRY_FILE'))
73
+ me = os.environ['INSTAR_MY_TMUX']
74
+ t2s = reg.get('topicToSession') or {}
75
+ hit = [k for k, v in t2s.items() if v == me]
76
+ print(hit[0] if hit else '')
77
+ except Exception:
78
+ print('')
79
+ " 2>/dev/null || echo "")
80
+ fi
23
81
 
24
- if [[ ! -f "$STATE_FILE" ]]; then
25
- # No active autonomous session — allow exit
82
+ # Helper: read a frontmatter field from an arbitrary state file (pipefail-safe).
83
+ fm_get_from() {
84
+ local file="$1" key="$2"
85
+ sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$file" 2>/dev/null | grep "^${key}:" | head -1 | sed "s/^${key}: *//" | tr -d '"' || true
86
+ }
87
+
88
+ # ── Select the state file (per-topic preferred; legacy fallback + migrate) ──
89
+ STATE_FILE=""
90
+ OWNED_VIA_TOPIC="false"
91
+ PER_TOPIC_FILE=""
92
+ [[ -n "$MY_TOPIC" ]] && PER_TOPIC_FILE="$MULTI_DIR/${MY_TOPIC}.local.md"
93
+
94
+ if [[ -n "$PER_TOPIC_FILE" ]] && [[ -f "$PER_TOPIC_FILE" ]]; then
95
+ STATE_FILE="$PER_TOPIC_FILE"
96
+ OWNED_VIA_TOPIC="true"
97
+ elif [[ -f "$LEGACY_STATE" ]]; then
98
+ # A legacy single-file job exists. If it belongs to MY topic, migrate it to the
99
+ # per-topic path (idempotent, never disrupts the running job — same content).
100
+ LEGACY_TOPIC=$(fm_get_from "$LEGACY_STATE" report_topic)
101
+ if [[ -n "$MY_TOPIC" ]] && [[ "$LEGACY_TOPIC" == "$MY_TOPIC" ]]; then
102
+ mkdir -p "$MULTI_DIR"
103
+ if mv "$LEGACY_STATE" "$PER_TOPIC_FILE" 2>/dev/null; then
104
+ STATE_FILE="$PER_TOPIC_FILE"; OWNED_VIA_TOPIC="true"
105
+ echo "[autonomous] migrated legacy state → $PER_TOPIC_FILE" >&2
106
+ else
107
+ STATE_FILE="$LEGACY_STATE"
108
+ fi
109
+ else
110
+ # Legacy job for a different/unknown topic — honor it via the legacy
111
+ # ownership logic below (preserves v1.2.55 behavior for in-flight jobs).
112
+ STATE_FILE="$LEGACY_STATE"
113
+ fi
114
+ else
115
+ # No autonomous job for this session anywhere — allow exit.
26
116
  exit 0
27
117
  fi
28
118
 
29
- # Parse YAML frontmatter
119
+ # ── Read the selected state file ──────────────────────────────────────
30
120
  FRONTMATTER=$(sed -n '/^---$/,/^---$/{ /^---$/d; p; }' "$STATE_FILE")
121
+ fm_get() {
122
+ local key="$1"
123
+ printf '%s\n' "$FRONTMATTER" | grep "^${key}:" | head -1 | sed "s/^${key}: *//" | tr -d '"' || true
124
+ }
31
125
 
32
- ACTIVE=$(echo "$FRONTMATTER" | grep '^active:' | sed 's/active: *//')
126
+ ACTIVE=$(fm_get active)
33
127
  if [[ "$ACTIVE" != "true" ]]; then
34
128
  exit 0
35
129
  fi
36
130
 
37
- # SESSION ISOLATION: Only block the session that started autonomous mode.
38
- # Uses self-bootstrapping: if no session_id in state file yet, the FIRST
39
- # session to trigger the hook claims it. All other sessions see a mismatch.
40
- STATE_SESSION=$(echo "$FRONTMATTER" | grep '^session_id:' | sed 's/^session_id: *//' | tr -d '"' || true)
131
+ # Paused (e.g. by quota-pressure load-shedding) allow exit until resumed.
132
+ PAUSED=$(fm_get paused)
133
+ if [[ "$PAUSED" == "true" ]]; then
134
+ echo "[autonomous] job paused allowing exit until resumed" >&2
135
+ exit 0
136
+ fi
41
137
 
42
- # VALIDATE: If state has a session_id but it's not a valid UUID, treat as empty.
43
- # Claude sometimes writes a custom string instead of the real $CLAUDE_CODE_SESSION_ID.
44
- # Non-UUID values will never match the real hook session_id, causing the hook to
45
- # fail-open and allow premature exit. By clearing invalid values, we fall through
46
- # to self-bootstrap, which captures the REAL session_id from the first hook call.
138
+ REPORT_TOPIC=$(fm_get report_topic)
139
+ # Channel that owns this job recovery note routes here. Default telegram for
140
+ # back-compat (state files written before channel-neutral delivery existed).
141
+ REPORT_CHANNEL=$(fm_get report_channel)
142
+ [[ -z "$REPORT_CHANNEL" ]] && REPORT_CHANNEL="telegram"
143
+ STATE_SESSION=$(fm_get session_id)
144
+ ITERATION=$(fm_get iteration)
145
+ DURATION_SECONDS=$(fm_get duration_seconds)
146
+ STARTED_AT=$(fm_get started_at)
147
+ COMPLETION_PROMISE=$(fm_get completion_promise)
148
+
149
+ # Validate recorded session_id is a real UUID. Claude sometimes writes a custom
150
+ # string instead of $CLAUDE_CODE_SESSION_ID; non-UUID values are treated as
151
+ # empty so the session-match backstop self-bootstraps from the real UUID.
47
152
  UUID_REGEX='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
48
153
  if [[ -n "$STATE_SESSION" ]] && ! [[ "$STATE_SESSION" =~ $UUID_REGEX ]]; then
49
- echo "[autonomous] Invalid session_id in state file (not UUID): '$STATE_SESSION' — falling back to self-bootstrap" >&2
154
+ echo "[autonomous] Invalid session_id in state file (not UUID): '$STATE_SESSION' — clearing" >&2
50
155
  STATE_SESSION=""
51
156
  fi
52
157
 
53
- HOOK_SESSION=$(echo "$HOOK_INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
54
-
55
- # If hook has no session_id fail OPEN (unknown context, don't trap)
56
- if [[ -z "$HOOK_SESSION" ]]; then
57
- echo "⚠️ Autonomous mode: No session_id in hook input — fail-open (allowing exit)" >&2
58
- exit 0
59
- fi
158
+ # ── Ownership decision ────────────────────────────────────────────────
159
+ # OWNER=true means: this session IS the autonomous worker; block its exit.
160
+ # OWNER_METHOD: topic (per-topic file) | topic-legacy | session | bootstrap | adopt-dead.
161
+ OWNER="false"
162
+ OWNER_METHOD=""
163
+ RESTART_DETECTED="false"
164
+
165
+ if [[ "$OWNED_VIA_TOPIC" == "true" ]]; then
166
+ # Reading MY topic's own file — ownership is implicit and unambiguous.
167
+ OWNER="true"; OWNER_METHOD="topic"
168
+ if [[ -n "$STATE_SESSION" ]] && [[ "$STATE_SESSION" != "$HOOK_SESSION" ]]; then
169
+ RESTART_DETECTED="true"
170
+ fi
171
+ else
172
+ # ── Legacy single-file path: v1.2.55 topic-keyed-or-liveness-backstop ──
173
+ OWNER_TMUX=""
174
+ if [[ -n "$REPORT_TOPIC" ]] && [[ -f "$REGISTRY_FILE" ]]; then
175
+ OWNER_TMUX=$(REPORT_TOPIC="$REPORT_TOPIC" python3 -c "
176
+ import json, os
177
+ try:
178
+ reg = json.load(open('$REGISTRY_FILE'))
179
+ print((reg.get('topicToSession') or {}).get(os.environ['REPORT_TOPIC'], ''))
180
+ except Exception:
181
+ print('')
182
+ " 2>/dev/null || echo "")
183
+ fi
60
184
 
61
- # SELF-BOOTSTRAP: If state has no session_id yet, claim it from this hook call.
62
- # The first session to fire the hook becomes the autonomous session.
63
- if [[ -z "$STATE_SESSION" ]]; then
64
- # Atomic claim: write session_id to state file
65
- TEMP_FILE="${STATE_FILE}.claim.$$"
66
- sed "s/^session_id:.*/session_id: \"${HOOK_SESSION}\"/" "$STATE_FILE" > "$TEMP_FILE"
67
- mv "$TEMP_FILE" "$STATE_FILE"
68
- STATE_SESSION="$HOOK_SESSION"
69
- echo "[autonomous] Session $HOOK_SESSION claimed autonomous mode" >&2
185
+ if [[ -n "$MY_TMUX" ]] && [[ -n "$OWNER_TMUX" ]]; then
186
+ if [[ "$MY_TMUX" == "$OWNER_TMUX" ]]; then
187
+ OWNER="true"; OWNER_METHOD="topic-legacy"
188
+ if [[ -n "$STATE_SESSION" ]] && [[ "$STATE_SESSION" != "$HOOK_SESSION" ]]; then
189
+ RESTART_DETECTED="true"
190
+ fi
191
+ else
192
+ exit 0 # legacy job belongs to a different session's topic
193
+ fi
194
+ else
195
+ # Topic unresolved — session-id backstop, liveness-gated.
196
+ if [[ -z "$STATE_SESSION" ]]; then
197
+ OWNER="true"; OWNER_METHOD="bootstrap"
198
+ elif [[ "$STATE_SESSION" == "$HOOK_SESSION" ]]; then
199
+ OWNER="true"; OWNER_METHOD="session"
200
+ else
201
+ OWNER_ALIVE="false"
202
+ if [[ -n "$TRANSCRIPT_PATH" ]]; then
203
+ OWNER_TRANSCRIPT="$(dirname "$TRANSCRIPT_PATH")/${STATE_SESSION}.jsonl"
204
+ if [[ -f "$OWNER_TRANSCRIPT" ]]; then
205
+ NOW_E=$(date +%s)
206
+ # GNU stat (-c %Y) first (Linux/CI); BSD stat (-f %m) fallback (macOS).
207
+ # GNU `stat -f` is filesystem mode and succeeds with non-numeric output,
208
+ # so BSD-first would mask the GNU path on Linux; numeric guard treats a
209
+ # bad mtime as 0 (very old → dead) rather than crashing the arithmetic.
210
+ MTIME=$(stat -c %Y "$OWNER_TRANSCRIPT" 2>/dev/null || stat -f %m "$OWNER_TRANSCRIPT" 2>/dev/null || echo 0)
211
+ [[ "$MTIME" =~ ^[0-9]+$ ]] || MTIME=0
212
+ AGE=$(( NOW_E - MTIME ))
213
+ if [[ $MTIME -gt 0 ]] && [[ $AGE -lt $LIVENESS_SECS ]]; then
214
+ OWNER_ALIVE="true"
215
+ fi
216
+ fi
217
+ fi
218
+ if [[ "$OWNER_ALIVE" == "true" ]]; then
219
+ exit 0 # a genuinely different, live session owns this — don't steal
220
+ fi
221
+ OWNER="true"; OWNER_METHOD="adopt-dead"; RESTART_DETECTED="true"
222
+ fi
223
+ fi
70
224
  fi
71
225
 
72
- # Different session allow exit (fail-open for non-autonomous sessions)
73
- if [[ "$STATE_SESSION" != "$HOOK_SESSION" ]]; then
226
+ if [[ "$OWNER" != "true" ]]; then
74
227
  exit 0
75
228
  fi
76
229
 
77
- # Same session — this IS the autonomous session, proceed with block logic
78
-
79
- ITERATION=$(echo "$FRONTMATTER" | grep '^iteration:' | sed 's/iteration: *//')
80
- DURATION_SECONDS=$(echo "$FRONTMATTER" | grep '^duration_seconds:' | sed 's/duration_seconds: *//')
81
- STARTED_AT=$(echo "$FRONTMATTER" | grep '^started_at:' | sed 's/started_at: *//' | tr -d '"')
82
- COMPLETION_PROMISE=$(echo "$FRONTMATTER" | grep '^completion_promise:' | sed 's/completion_promise: *//' | tr -d '"')
83
- REPORT_TOPIC=$(echo "$FRONTMATTER" | grep '^report_topic:' | sed 's/report_topic: *//' | tr -d '"')
230
+ # ── This IS the autonomous session. Terminal checks first. ────────────
84
231
 
85
232
  # Validate iteration
86
233
  if [[ ! "$ITERATION" =~ ^[0-9]+$ ]]; then
87
234
  echo "⚠️ Autonomous mode: State file corrupted (bad iteration)" >&2
88
- rm "$STATE_FILE"
235
+ rm -f "$STATE_FILE"
89
236
  exit 0
90
237
  fi
91
238
 
92
- # Check duration expiry
239
+ # Duration expiry. Fail-SAFE: if started_at can't be parsed (START_EPOCH falls
240
+ # back to 0/empty), do NOT expire — an unparseable timestamp must never cause a
241
+ # premature exit (that is the very failure class this hook exists to prevent).
242
+ REMAINING_MIN=""
93
243
  if [[ "$DURATION_SECONDS" =~ ^[0-9]+$ ]] && [[ $DURATION_SECONDS -gt 0 ]]; then
94
- START_EPOCH=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$STARTED_AT" +%s 2>/dev/null || date -d "$STARTED_AT" +%s 2>/dev/null || echo "0")
95
- NOW_EPOCH=$(date +%s)
96
- ELAPSED=$(( NOW_EPOCH - START_EPOCH ))
97
- if [[ $ELAPSED -ge $DURATION_SECONDS ]]; then
98
- echo "⏰ Autonomous mode: Duration expired ($ELAPSED seconds elapsed)."
99
- echo " Session is free to exit."
100
- rm "$STATE_FILE"
101
- exit 0
244
+ START_EPOCH=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$STARTED_AT" +%s 2>/dev/null || date -d "$STARTED_AT" +%s 2>/dev/null || echo "0")
245
+ if [[ "$START_EPOCH" =~ ^[0-9]+$ ]] && [[ $START_EPOCH -gt 0 ]]; then
246
+ NOW_EPOCH=$(date +%s)
247
+ ELAPSED=$(( NOW_EPOCH - START_EPOCH ))
248
+ if [[ $ELAPSED -ge $DURATION_SECONDS ]]; then
249
+ echo " Autonomous mode: Duration expired ($ELAPSED seconds elapsed)."
250
+ echo " Session is free to exit."
251
+ rm -f "$STATE_FILE"
252
+ exit 0
253
+ fi
254
+ REMAINING=$(( DURATION_SECONDS - ELAPSED ))
255
+ REMAINING_MIN=$(( REMAINING / 60 ))
256
+ else
257
+ echo "[autonomous] started_at unparseable ('$STARTED_AT') — skipping duration-expiry check (fail-safe: keep running)" >&2
102
258
  fi
103
- REMAINING=$(( DURATION_SECONDS - ELAPSED ))
104
- REMAINING_MIN=$(( REMAINING / 60 ))
105
259
  fi
106
260
 
107
- # Check for emergency stop (look in recent messages)
108
- # The MessageSentinel handles this at the messaging layer, but also check here
261
+ # Emergency stop (global halts every autonomous job on its next fire)
109
262
  if [[ -f ".instar/autonomous-emergency-stop" ]]; then
110
263
  echo "🛑 Autonomous mode: Emergency stop detected."
111
- rm "$STATE_FILE"
112
- rm -f ".instar/autonomous-emergency-stop"
264
+ rm -f "$STATE_FILE"
265
+ # NOTE: the emergency flag is left in place so OTHER topics' hooks also see it
266
+ # and clear their own per-topic files; stop-all clears the flag when complete.
113
267
  exit 0
114
268
  fi
115
269
 
116
- # Get transcript and check for completion promise
117
- TRANSCRIPT_PATH=$(echo "$HOOK_INPUT" | jq -r '.transcript_path' 2>/dev/null || echo "")
118
-
270
+ # Completion promise (genuine completion)
119
271
  if [[ -n "$TRANSCRIPT_PATH" ]] && [[ -f "$TRANSCRIPT_PATH" ]]; then
120
- # Check last assistant message for completion promise
121
272
  LAST_LINE=$(grep '"role":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1 || echo "")
122
-
123
273
  if [[ -n "$LAST_LINE" ]]; then
124
- LAST_OUTPUT=$(echo "$LAST_LINE" | jq -r '
125
- .message.content |
126
- map(select(.type == "text")) |
127
- map(.text) |
128
- join("\n")
274
+ LAST_OUTPUT=$(printf '%s' "$LAST_LINE" | jq -r '
275
+ .message.content | map(select(.type == "text")) | map(.text) | join("\n")
129
276
  ' 2>/dev/null || echo "")
130
-
131
- # Check for completion promise in <promise> tags
132
277
  if [[ -n "$COMPLETION_PROMISE" ]] && [[ "$COMPLETION_PROMISE" != "null" ]]; then
133
- PROMISE_TEXT=$(echo "$LAST_OUTPUT" | perl -0777 -pe 's/.*?<promise>(.*?)<\/promise>.*/$1/s; s/^\s+|\s+$//g; s/\s+/ /g' 2>/dev/null || echo "")
134
-
278
+ PROMISE_TEXT=$(printf '%s' "$LAST_OUTPUT" | perl -0777 -pe 's/.*?<promise>(.*?)<\/promise>.*/$1/s; s/^\s+|\s+$//g; s/\s+/ /g' 2>/dev/null || echo "")
135
279
  if [[ -n "$PROMISE_TEXT" ]] && [[ "$PROMISE_TEXT" = "$COMPLETION_PROMISE" ]]; then
136
280
  echo "✅ Autonomous mode: Completion promise detected — <promise>$COMPLETION_PROMISE</promise>"
137
281
  echo " Session is free to exit. Good work!"
138
- rm "$STATE_FILE"
282
+ rm -f "$STATE_FILE"
139
283
  exit 0
140
284
  fi
141
285
  fi
142
286
  fi
143
287
  fi
144
288
 
145
- # Not complete block exit and feed task list back
289
+ # ── Not terminal: we are continuing. Handle restart-resume recovery note. ──
290
+ record_session_id() {
291
+ local new_id="$1"
292
+ local tmp="${STATE_FILE}.sid.$$"
293
+ if grep -q '^session_id:' "$STATE_FILE"; then
294
+ sed "s/^session_id:.*/session_id: \"${new_id}\"/" "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE"
295
+ fi
296
+ }
297
+
298
+ # Channel-neutral delivery seam (no Telegram assumption). Telegram wired; other
299
+ # channels owned by the Channel Parity initiative. Audit record is the source of truth.
300
+ deliver_recovery_note() {
301
+ local channel="$1" target="$2" text="$3"
302
+ [[ -z "$target" ]] && return 0
303
+ case "$channel" in
304
+ telegram)
305
+ if [[ -x ".instar/scripts/telegram-reply.sh" ]]; then
306
+ printf '%s\n' "$text" | .instar/scripts/telegram-reply.sh "$target" >/dev/null 2>&1 || true
307
+ elif [[ -x ".claude/scripts/telegram-reply.sh" ]]; then
308
+ printf '%s\n' "$text" | .claude/scripts/telegram-reply.sh "$target" >/dev/null 2>&1 || true
309
+ fi
310
+ ;;
311
+ *)
312
+ echo "[autonomous] recovery note for channel '$channel' recorded to audit; live delivery pending the Channel Parity initiative" >&2
313
+ ;;
314
+ esac
315
+ }
316
+
317
+ if [[ "$RESTART_DETECTED" == "true" ]] && [[ "$STATE_SESSION" != "$HOOK_SESSION" ]]; then
318
+ ITER_LABEL="${ITERATION:-?}"
319
+ NOTE="Heads up — my session restarted mid-run and I've picked the autonomous job back up (topic ${REPORT_TOPIC:-?}, iteration ${ITER_LABEL}). No action needed."
320
+ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
321
+ printf '{"ts":"%s","event":"restart-resume","channel":"%s","topic":"%s","oldSession":"%s","newSession":"%s","method":"%s","iteration":"%s"}\n' \
322
+ "$TS" "$REPORT_CHANNEL" "${REPORT_TOPIC:-}" "$STATE_SESSION" "$HOOK_SESSION" "$OWNER_METHOD" "$ITER_LABEL" >> "$RECOVERY_AUDIT" 2>/dev/null || true
323
+ deliver_recovery_note "$REPORT_CHANNEL" "$REPORT_TOPIC" "$NOTE"
324
+ echo "[autonomous] restart-resume: channel=$REPORT_CHANNEL topic=${REPORT_TOPIC:-?} old=$STATE_SESSION new=$HOOK_SESSION method=$OWNER_METHOD" >&2
325
+ fi
326
+
327
+ # Reconcile recorded session_id to live (covers restart, bootstrap, adopt).
328
+ if [[ "$STATE_SESSION" != "$HOOK_SESSION" ]]; then
329
+ record_session_id "$HOOK_SESSION"
330
+ fi
331
+
332
+ # ── Continue the job: increment iteration, feed the task back. ────────
146
333
  NEXT_ITERATION=$((ITERATION + 1))
147
334
 
148
- # Extract prompt (everything after closing ---)
149
335
  PROMPT_TEXT=$(awk '/^---$/{i++; next} i>=2' "$STATE_FILE")
150
-
151
336
  if [[ -z "$PROMPT_TEXT" ]]; then
152
337
  echo "⚠️ Autonomous mode: State file has no task content" >&2
153
- rm "$STATE_FILE"
338
+ rm -f "$STATE_FILE"
154
339
  exit 0
155
340
  fi
156
341
 
157
- # Update iteration counter
158
- TEMP_FILE="${STATE_FILE}.tmp.$$"
159
- sed "s/^iteration: .*/iteration: $NEXT_ITERATION/" "$STATE_FILE" > "$TEMP_FILE"
160
- mv "$TEMP_FILE" "$STATE_FILE"
342
+ TEMP_FILE="${STATE_FILE}.iter.$$"
343
+ sed "s/^iteration: .*/iteration: $NEXT_ITERATION/" "$STATE_FILE" > "$TEMP_FILE" && mv "$TEMP_FILE" "$STATE_FILE"
161
344
 
162
345
  # ── Progress Report Check ──
163
- # Check if it's time to send a progress report
164
- REPORT_INTERVAL=$(echo "$FRONTMATTER" | grep '^report_interval:' | sed 's/report_interval: *//' | tr -d '"')
165
- LAST_REPORT_AT=$(echo "$FRONTMATTER" | grep '^last_report_at:' | sed 's/last_report_at: *//' | tr -d '"')
346
+ REPORT_INTERVAL=$(fm_get report_interval)
347
+ LAST_REPORT_AT=$(fm_get last_report_at)
166
348
 
167
- # Convert report interval to seconds
168
349
  REPORT_INTERVAL_SECS=1800 # default 30 minutes
169
350
  if [[ "$REPORT_INTERVAL" =~ ^([0-9]+)m$ ]]; then
170
351
  REPORT_INTERVAL_SECS=$(( ${BASH_REMATCH[1]} * 60 ))
@@ -174,37 +355,30 @@ fi
174
355
 
175
356
  REPORT_DUE="false"
176
357
  NOW_EPOCH=$(date +%s)
177
-
178
- if [[ -z "$LAST_REPORT_AT" ]] || [[ "$LAST_REPORT_AT" == "null" ]] || [[ "$LAST_REPORT_AT" == '""' ]]; then
179
- # No report sent yet — due if we've been running for at least one interval
358
+ if [[ -z "$LAST_REPORT_AT" ]] || [[ "$LAST_REPORT_AT" == "null" ]]; then
180
359
  if [[ -n "$STARTED_AT" ]]; then
181
- START_EPOCH_R=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$STARTED_AT" +%s 2>/dev/null || date -d "$STARTED_AT" +%s 2>/dev/null || echo "0")
360
+ START_EPOCH_R=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$STARTED_AT" +%s 2>/dev/null || date -d "$STARTED_AT" +%s 2>/dev/null || echo "0")
182
361
  ELAPSED_SINCE_START=$(( NOW_EPOCH - START_EPOCH_R ))
183
362
  if [[ $ELAPSED_SINCE_START -ge $REPORT_INTERVAL_SECS ]]; then
184
363
  REPORT_DUE="true"
185
364
  fi
186
365
  fi
187
366
  else
188
- # Check time since last report
189
- LAST_REPORT_EPOCH=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$LAST_REPORT_AT" +%s 2>/dev/null || date -d "$LAST_REPORT_AT" +%s 2>/dev/null || echo "0")
367
+ LAST_REPORT_EPOCH=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$LAST_REPORT_AT" +%s 2>/dev/null || date -d "$LAST_REPORT_AT" +%s 2>/dev/null || echo "0")
190
368
  ELAPSED_SINCE_REPORT=$(( NOW_EPOCH - LAST_REPORT_EPOCH ))
191
369
  if [[ $ELAPSED_SINCE_REPORT -ge $REPORT_INTERVAL_SECS ]]; then
192
370
  REPORT_DUE="true"
193
371
  fi
194
372
  fi
195
373
 
196
- # If report is due, update last_report_at in state file
197
374
  REPORT_DIRECTIVE=""
198
375
  if [[ "$REPORT_DUE" == "true" ]]; then
199
376
  REPORT_NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
200
- # Update or add last_report_at in frontmatter
201
377
  if grep -q '^last_report_at:' "$STATE_FILE"; then
202
- TEMP_FILE2="${STATE_FILE}.tmp2.$$"
203
- sed "s/^last_report_at: .*/last_report_at: \"$REPORT_NOW\"/" "$STATE_FILE" > "$TEMP_FILE2"
204
- mv "$TEMP_FILE2" "$STATE_FILE"
378
+ TEMP_FILE2="${STATE_FILE}.rpt.$$"
379
+ sed "s/^last_report_at: .*/last_report_at: \"$REPORT_NOW\"/" "$STATE_FILE" > "$TEMP_FILE2" && mv "$TEMP_FILE2" "$STATE_FILE"
205
380
  else
206
- # Add last_report_at before the closing ---
207
- TEMP_FILE2="${STATE_FILE}.tmp2.$$"
381
+ TEMP_FILE2="${STATE_FILE}.rpt.$$"
208
382
  sed "0,/^---$/! { /^---$/i\\
209
383
  last_report_at: \"$REPORT_NOW\"
210
384
  }" "$STATE_FILE" > "$TEMP_FILE2" 2>/dev/null && mv "$TEMP_FILE2" "$STATE_FILE" || true
@@ -10,6 +10,7 @@ set -euo pipefail
10
10
  GOAL=""
11
11
  DURATION="4h"
12
12
  REPORT_TOPIC=""
13
+ REPORT_CHANNEL="telegram" # channel that owns this job; recovery note routes here (telegram|slack|whatsapp|imessage)
13
14
  LEVEL_UP="false"
14
15
  TASKS=""
15
16
  COMPLETION_PROMISE=""
@@ -29,6 +30,10 @@ while [[ $# -gt 0 ]]; do
29
30
  REPORT_TOPIC="$2"
30
31
  shift 2
31
32
  ;;
33
+ --report-channel)
34
+ REPORT_CHANNEL="$2"
35
+ shift 2
36
+ ;;
32
37
  --level-up)
33
38
  LEVEL_UP="true"
34
39
  shift
@@ -88,10 +93,51 @@ if [[ -z "$COMPLETION_PROMISE" ]]; then
88
93
  COMPLETION_PROMISE="ALL_TASKS_COMPLETE"
89
94
  fi
90
95
 
91
- # Create state file
96
+ # ── Multi-session start gate: concurrency cap + quota (refuse-new) ──
97
+ # Primary check is the server (precise active-count + QuotaTracker). If the
98
+ # server is unreachable, fall back to a local file-count cap so the cap still
99
+ # holds. Starting/restarting THIS topic's own job is always allowed.
100
+ if [[ -n "$REPORT_TOPIC" ]]; then
101
+ PORT=$(python3 -c "import json;print(json.load(open('.instar/config.json')).get('port',4040))" 2>/dev/null || echo 4040)
102
+ AUTH=$(python3 -c "import json;print(json.load(open('.instar/config.json')).get('authToken',''))" 2>/dev/null || echo "")
103
+ CAN_START=$(curl -s -m 3 -H "Authorization: Bearer $AUTH" "http://localhost:${PORT}/autonomous/can-start?priority=medium" 2>/dev/null || echo "")
104
+ ALLOWED=$(printf '%s' "$CAN_START" | python3 -c "import sys,json
105
+ try: print(json.load(sys.stdin).get('allowed'))
106
+ except Exception: print('unknown')" 2>/dev/null || echo "unknown")
107
+ ALREADY_RUNNING="false"
108
+ [[ -f ".instar/autonomous/${REPORT_TOPIC}.local.md" ]] && ALREADY_RUNNING="true"
109
+ if [[ "$ALLOWED" == "False" ]] && [[ "$ALREADY_RUNNING" != "true" ]]; then
110
+ REASON=$(printf '%s' "$CAN_START" | python3 -c "import sys,json
111
+ try: print(json.load(sys.stdin).get('reason',''))
112
+ except Exception: print('')" 2>/dev/null || echo "")
113
+ echo "❌ Autonomous start refused: ${REASON:-cap or quota}" >&2
114
+ echo " Stop a running job (POST /autonomous/sessions/<topic>/stop) or raise autonomousSessions.maxConcurrent." >&2
115
+ exit 1
116
+ fi
117
+ if [[ "$ALLOWED" == "unknown" ]] && [[ "$ALREADY_RUNNING" != "true" ]]; then
118
+ MAX_CONCURRENT=$(python3 -c "import json;print((json.load(open('.instar/config.json')).get('autonomousSessions') or {}).get('maxConcurrent',5))" 2>/dev/null || echo 5)
119
+ COUNT=$(ls .instar/autonomous/*.local.md 2>/dev/null | grep -cv "/${REPORT_TOPIC}\.local\.md$")
120
+ COUNT=${COUNT:-0}
121
+ if [[ "$COUNT" =~ ^[0-9]+$ ]] && [[ "$MAX_CONCURRENT" =~ ^[0-9]+$ ]] && [[ $COUNT -ge $MAX_CONCURRENT ]]; then
122
+ echo "❌ Autonomous start refused: concurrency cap reached ($COUNT/$MAX_CONCURRENT) [server unreachable; local check]." >&2
123
+ exit 1
124
+ fi
125
+ fi
126
+ fi
127
+
128
+ # Create state file. Multi-session: each topic gets its own state file at
129
+ # .instar/autonomous/<topicId>.local.md so multiple topics run concurrent
130
+ # autonomous jobs without collision. With no report topic, fall back to the
131
+ # legacy single-file path (one-at-a-time, back-compat).
92
132
  mkdir -p .instar
133
+ if [[ -n "$REPORT_TOPIC" ]]; then
134
+ mkdir -p .instar/autonomous
135
+ STATE_PATH=".instar/autonomous/${REPORT_TOPIC}.local.md"
136
+ else
137
+ STATE_PATH=".instar/autonomous-state.local.md"
138
+ fi
93
139
 
94
- cat > .instar/autonomous-state.local.md <<EOF
140
+ cat > "$STATE_PATH" <<EOF
95
141
  ---
96
142
  active: true
97
143
  iteration: 1
@@ -102,6 +148,7 @@ duration_seconds: $DURATION_SECONDS
102
148
  started_at: "$STARTED_AT"
103
149
  end_at: "$END_AT"
104
150
  report_topic: "$REPORT_TOPIC"
151
+ report_channel: "$REPORT_CHANNEL"
105
152
  report_interval: "$REPORT_INTERVAL"
106
153
  last_report_at: ""
107
154
  level_up: $LEVEL_UP
@@ -0,0 +1,66 @@
1
+ /**
2
+ * AutonomousSessions — multi-session autonomy control surface.
3
+ *
4
+ * Each autonomous job has its own state file at
5
+ * `<stateDir>/autonomous/<topicId>.local.md`. A legacy single-file job at
6
+ * `<stateDir>/autonomous-state.local.md` is also recognized for back-compat.
7
+ *
8
+ * This module is the read/control layer over those files: list active jobs,
9
+ * enforce the concurrency cap + quota at start, and stop jobs (all or one).
10
+ * The stop hook (`autonomous-stop-hook.sh`) is the per-session enforcer; this
11
+ * module is what the server routes, the start path, and the stop-everything
12
+ * path call. It never traps a session itself — it only reports and clears state.
13
+ */
14
+ import type { JobPriority } from './types.js';
15
+ /** Default concurrent autonomous-job cap when config doesn't specify one. */
16
+ export declare const DEFAULT_MAX_CONCURRENT_AUTONOMOUS = 5;
17
+ export interface AutonomousJobSummary {
18
+ topic: string | null;
19
+ file: string;
20
+ active: boolean;
21
+ paused: boolean;
22
+ goal: string | null;
23
+ iteration: number | null;
24
+ startedAt: string | null;
25
+ reportChannel: string | null;
26
+ }
27
+ /** All autonomous jobs (per-topic files + a legacy single file if present). */
28
+ export declare function listAutonomousJobs(stateDir: string): AutonomousJobSummary[];
29
+ /** Active (and not paused) autonomous jobs — what counts against the cap. */
30
+ export declare function activeAutonomousJobs(stateDir: string): AutonomousJobSummary[];
31
+ export interface CanStartDeps {
32
+ stateDir: string;
33
+ maxConcurrent: number;
34
+ quotaCanStart?: (priority?: JobPriority) => {
35
+ allowed: boolean;
36
+ reason: string;
37
+ };
38
+ priority?: JobPriority;
39
+ }
40
+ export interface CanStartResult {
41
+ allowed: boolean;
42
+ reason: string;
43
+ activeCount: number;
44
+ maxConcurrent: number;
45
+ }
46
+ /**
47
+ * Decide whether a new autonomous job may start: concurrency cap first
48
+ * (refuse-new at the cap), then quota (refuse-new under budget pressure).
49
+ * Never preempts a running job — that's the pause path, handled elsewhere.
50
+ */
51
+ export declare function canStartAutonomousJob(deps: CanStartDeps): CanStartResult;
52
+ /** Stop exactly one topic's job (removes its state file). Returns true if removed. */
53
+ export declare function stopAutonomousTopic(stateDir: string, topic: string): boolean;
54
+ export interface StopAllResult {
55
+ stoppedTopics: string[];
56
+ stoppedLegacy: boolean;
57
+ }
58
+ /**
59
+ * Stop every autonomous job. Removes all per-topic files + the legacy file and
60
+ * writes the emergency-stop flag so any session whose hook fires before its file
61
+ * is gone also stands down. The flag is the belt; removing files is the suspenders.
62
+ */
63
+ export declare function stopAllAutonomousJobs(stateDir: string): StopAllResult;
64
+ /** Pause one topic's job (hook will allow exit until resumed). Returns true if updated. */
65
+ export declare function pauseAutonomousTopic(stateDir: string, topic: string): boolean;
66
+ //# sourceMappingURL=AutonomousSessions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AutonomousSessions.d.ts","sourceRoot":"","sources":["../../src/core/AutonomousSessions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAG9C,6EAA6E;AAC7E,eAAO,MAAM,iCAAiC,IAAI,CAAC;AAEnD,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAmCD,+EAA+E;AAC/E,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,oBAAoB,EAAE,CAmB3E;AAED,6EAA6E;AAC7E,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,oBAAoB,EAAE,CAE7E;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,CAAC,QAAQ,CAAC,EAAE,WAAW,KAAK;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACjF,QAAQ,CAAC,EAAE,WAAW,CAAC;CACxB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,YAAY,GAAG,cAAc,CAmBxE;AAED,sFAAsF;AACtF,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAO5E;AAED,MAAM,WAAW,aAAa;IAC5B,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,aAAa,EAAE,OAAO,CAAC;CACxB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,aAAa,CAwBrE;AAED,2FAA2F;AAC3F,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAW7E"}