← Files NightshiftARCHIVED FILE
runtime/windows/watchman.ps1
39.7 KB · Oct 2, 2026 · 00:30 UTC
param(
[string]$Project = [Environment]::CurrentDirectory,
[Parameter(Mandatory = $true)]
[ValidateSet('claude', 'codex', 'cursor')]
[string]$HostName,
[int]$IntervalMinutes = -1,
[string]$Agent = '',
[int]$MaxWakes = 0
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
$utf8 = New-Object Text.UTF8Encoding($false)
$pluginRoot = Resolve-Path (Join-Path $PSScriptRoot '../..')
Import-Module (Join-Path $pluginRoot 'lib/Nightshift.psm1') -Force -DisableNameChecking
function Write-NSLogLine {
param([Parameter(Mandatory = $true)][string]$Message)
if (Test-Path -LiteralPath $ns -PathType Container) {
$line = '{0} - {1}{2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message, [Environment]::NewLine
[IO.File]::AppendAllText($log, $line, $utf8)
}
}
function Get-NSSessionValue {
param([Parameter(Mandatory = $true)][string]$Name)
$session = Read-NSSession $ns
if ($null -eq $session) {
return ''
}
return [string]$session.$Name
}
function Test-NSDeadlinePassed {
$path = Get-NSLayoutPath $ns 'deadline'
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
return $false
}
if (Test-NSReparsePoint $path) {
return $false
}
try {
$value = ([IO.File]::ReadAllText($path)).Trim()
return $value -match '^[0-9]+$' -and (Get-NSUnixTime) -ge [long]$value
}
catch {
return $false
}
}
function Test-NSRealEnded {
$path = Get-NSLayoutPath $ns 'ended'
return ((Test-Path -LiteralPath $path -PathType Leaf) -and -not (Test-NSReparsePoint $path))
}
function Test-NSRealSessionEnd {
$path = Get-NSLayoutPath $ns 'session-end'
return ((Test-Path -LiteralPath $path -PathType Leaf) -and -not (Test-NSReparsePoint $path))
}
function Get-NSTranscript {
$recorded = Get-NSSessionValue 'Transcript'
if (-not [string]::IsNullOrEmpty($recorded) -and (Test-Path -LiteralPath $recorded -PathType Leaf)) {
return $recorded
}
$override = [string]$env:NIGHTSHIFT_WATCH_TRANSCRIPTS
if (-not [string]::IsNullOrEmpty($override) -and (Test-Path -LiteralPath $override -PathType Leaf)) {
return $override
}
$directory = ''
if (-not [string]::IsNullOrEmpty($override) -and (Test-Path -LiteralPath $override -PathType Container)) {
$directory = $override
}
elseif ($HostName -eq 'claude') {
$slug = $workspace -replace '[^A-Za-z0-9]', '-'
$directory = Join-Path $HOME ".claude/projects/$slug"
}
else {
return ''
}
if (-not (Test-Path -LiteralPath $directory -PathType Container)) {
return ''
}
$latest = Get-ChildItem -LiteralPath $directory -Filter '*.jsonl' -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if ($null -eq $latest) {
return ''
}
return $latest.FullName
}
function Get-NSLastConversationLine {
$transcript = Get-NSTranscript
if ([string]::IsNullOrEmpty($transcript)) {
return ''
}
try {
$lines = @(Get-Content -LiteralPath $transcript -Tail 25 -ErrorAction Stop)
}
catch {
return ''
}
$last = ''
foreach ($line in $lines) {
if ($line -match '[^\\]"type"\s*:\s*"(user|assistant)"') {
$last = $line
}
}
return $last
}
function Test-NSOwnerPaused {
if ($HostName -ne 'claude') {
return $false
}
return (Get-NSLastConversationLine) -match 'Request interrupted by user'
}
function Test-NSErroredTail {
if ($HostName -ne 'claude') {
return $false
}
$transcript = Get-NSTranscript
if ([string]::IsNullOrEmpty($transcript)) {
return $false
}
try {
$lines = @(Get-Content -LiteralPath $transcript -Tail 400 -ErrorAction Stop)
}
catch {
return $false
}
$last = ''
foreach ($line in $lines) {
if ($line -match '[^\\]"type"\s*:\s*"(user|assistant)"') {
$last = $line
}
}
return $last -match '[^\\]"isApiErrorMessage"\s*:\s*true'
}
# Test-NSUsageLimitRevivalOff - true when the owner set watchAfterUsageLimit to false.
function Test-NSUsageLimitRevivalOff {
return ([string](Get-NSRule $workspace 'watchAfterUsageLimit' ([string]$env:NIGHTSHIFT_WATCH_AFTER_USAGE_LIMIT))) -ceq 'false'
}
# Test-NSUsageLimitedTail - the errored Claude tail names a usage limit, not a transient failure.
function Test-NSUsageLimitedTail {
$transcript = Get-NSTranscript
if ([string]::IsNullOrEmpty($transcript)) { return $false }
try { $lines = @(Get-Content -LiteralPath $transcript -Tail 400 -ErrorAction Stop) } catch { return $false }
$last = ''
foreach ($line in $lines) {
if ($line -match '[^\\]"isApiErrorMessage"\s*:\s*true') { $last = $line }
}
return $last -match '(?i)usage[ _-]?limit'
}
# Host-general evidence for the watchman's own backoff, distinct from Test-NSErroredTail
# (which only reads Claude's structured transcript field). Looks for the same signal words
# the POSIX watchman keys its backoff on: a rate limit, a usage limit, HTTP 429/529, or a
# bare mention of "api" in the transcript tail.
function Test-NSApiFailureEvidence {
$transcript = Get-NSTranscript
if ([string]::IsNullOrEmpty($transcript)) {
return $false
}
try {
$tail = @(Get-Content -LiteralPath $transcript -Tail 25 -ErrorAction Stop)
}
catch {
return $false
}
$text = $tail -join [Environment]::NewLine
return $text -match '(?i)rate limit|usage limit|\b429\b|\b529\b|\bapi\b'
}
$script:TranscriptStamp = ''
$script:WedgeSeen = [long]-1
function Set-NSTranscriptBaseline {
$transcript = Get-NSTranscript
if ([string]::IsNullOrEmpty($transcript)) {
$script:TranscriptStamp = ''
return
}
try {
$item = Get-Item -LiteralPath $transcript -ErrorAction Stop
$script:TranscriptStamp = "$($item.Length):$($item.LastWriteTimeUtc.Ticks)"
}
catch {
$script:TranscriptStamp = ''
}
}
function Test-NSTranscriptPulse {
$transcript = Get-NSTranscript
if ([string]::IsNullOrEmpty($transcript)) {
return $false
}
try {
$item = Get-Item -LiteralPath $transcript -ErrorAction Stop
$current = "$($item.Length):$($item.LastWriteTimeUtc.Ticks)"
return [string]::IsNullOrEmpty($script:TranscriptStamp) -or $current -ne $script:TranscriptStamp
}
catch {
return $false
}
}
function Get-NSRegistryState {
if ($HostName -ne 'claude') {
return 'Unavailable'
}
$sessionId = Get-NSSessionValue 'SessionId'
if ([string]::IsNullOrEmpty($sessionId)) {
return 'Unavailable'
}
try {
$output = & claude agents --json 2>$null | Out-String
if ($LASTEXITCODE -ne 0 -or $output.TrimStart()[0] -ne '[') {
return 'Unavailable'
}
if ($output.Contains('"' + $sessionId + '"')) {
return 'Present'
}
return 'Absent'
}
catch {
return 'Unavailable'
}
}
function Get-NSCursorWorkerId {
$path = Get-NSLayoutPath $ns 'worker'
if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or (Test-NSReparsePoint $path)) {
return ''
}
try {
$id = ([IO.File]::ReadAllLines($path) | Select-Object -First 1)
if ([string]::IsNullOrWhiteSpace($id)) { return '' }
return $id.Trim()
}
catch {
return ''
}
}
function Write-NSCursorWorkerId {
param([Parameter(Mandatory = $true)][string]$WorkerId)
if ($WorkerId -match '[\r\n/\\]') {
return $false
}
$path = Get-NSLayoutPath $ns 'worker'
try {
return Write-NSAtomicLines -Path $path -Lines @($WorkerId) -Private
}
catch {
return $false
}
}
function Resolve-NSCursorWorkerId {
$existing = Get-NSCursorWorkerId
if (-not [string]::IsNullOrEmpty($existing)) {
return $existing
}
if (Test-NSMintFailed) {
return ''
}
$transcript = Get-NSSessionValue 'Transcript'
$origin = Get-NSSessionValue 'SessionId'
if ($transcript -match '[/\\]\.cursor[/\\]chats([/\\]|$)' -and -not [string]::IsNullOrEmpty($origin)) {
if (Write-NSCursorWorkerId $origin) { return $origin }
return ''
}
if (-not [string]::IsNullOrEmpty($Agent)) {
$minted = if (-not [string]::IsNullOrEmpty($env:NIGHTSHIFT_CURSOR_TEST_WORKER)) {
$env:NIGHTSHIFT_CURSOR_TEST_WORKER
} else {
'minted-cli-worker'
}
if (Write-NSCursorWorkerId $minted) { return $minted }
return ''
}
try {
$minted = (& agent create-chat 2>$null | Out-String).Trim()
}
catch {
Write-NSMintFailed
return ''
}
if ([string]::IsNullOrEmpty($minted) -or $minted -match '[\r\n/\\]') {
Write-NSMintFailed
return ''
}
if (Write-NSCursorWorkerId $minted) { return $minted }
return ''
}
function Get-NSHostProcessState {
if ($HostName -eq 'cursor') {
return 'Absent'
}
try {
$processes = @(Get-Process -Name $HostName -ErrorAction SilentlyContinue)
if ($processes.Count -gt 0) {
return 'Present'
}
return 'Absent'
}
catch {
return 'Unavailable'
}
}
function Test-NSMintFailed {
$path = Get-NSLayoutPath $ns 'mint-failed'
return ((Test-Path -LiteralPath $path -PathType Leaf) -and -not (Test-NSReparsePoint $path))
}
function Write-NSMintFailed {
$path = Get-NSLayoutPath $ns 'mint-failed'
if (Test-NSReparsePoint $path) {
Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue
}
$line = '{0}{1}' -f (Get-NSUnixTime), [Environment]::NewLine
[IO.File]::WriteAllText($path, $line, $utf8)
}
function Get-NSSiteVerdict {
if (Test-NSOwnerPaused) {
return 'esc'
}
if (Test-NSTranscriptPulse) {
return 'alive'
}
if (Test-NSPulseFresh $ns $IntervalMinutes) {
return 'alive'
}
if ($HostName -eq 'cursor') {
$session = Read-NSSession $ns
$processState = 'Absent'
if ($null -ne $session -and -not [string]::IsNullOrEmpty($session.ProcessId)) {
$processState = Test-NSRecordedProcess $session.ProcessId $session.Start
if ($processState -eq 'Alive') {
return 'silent'
}
}
if (Test-NSLeasePidLive $ns) {
return 'silent'
}
if (-not (Test-NSPulseStale $ns $IntervalMinutes $script:WatchStart)) {
return 'silent'
}
if ($processState -eq 'Unavailable') {
return 'unavailable'
}
return 'dead'
}
if ($HostName -eq 'codex') {
$session = Read-NSSession $ns
# A turn that ended on an API error is a wedge whatever the process evidence says, until the
# rollout moves again. The gap is recorded once, from the moment the turn failed; a usage
# limit waits for the reset time Codex reported.
$rollout = Get-NSTranscript
$turnError = Get-NSCodexTurnError $rollout
if (-not [string]::IsNullOrEmpty($turnError) -and -not (Test-NSTranscriptPulse)) {
$failedAt = Get-NSCodexTurnErrorAt $rollout
if ($failedAt -ne $script:WedgeSeen) {
$script:WedgeSeen = $failedAt
$null = Write-NSUsagePause $ns "the session stopped on an API error ($turnError)" $failedAt
Write-NSLogLine "watchman: the last turn ended on an API error ($turnError) - the session is wedged, not working"
}
$resetAt = Get-NSCodexLimitReset $rollout
# A usage limit is revived once it resets, unless the owner turned that off
# (watchAfterUsageLimit false): then the limit is recorded and the shift waits for them.
if ($turnError -ceq 'usage_limit_exceeded' -and ((Test-NSUsageLimitRevivalOff) -or $resetAt -gt (Get-NSUnixTime))) {
Write-NSReason $ns 'usage-limit' ([string]$resetAt)
return 'usage-limit'
}
Write-NSReason $ns 'api-error' $turnError
return 'wedge'
}
$processState = 'Absent'
if ($null -ne $session -and -not [string]::IsNullOrEmpty($session.ProcessId)) {
$processState = Test-NSRecordedProcess $session.ProcessId $session.Start
if ($processState -eq 'Alive') {
return 'silent'
}
}
if (-not (Test-NSPulseStale $ns $IntervalMinutes $script:WatchStart)) {
return 'silent'
}
if ($processState -eq 'Unavailable') {
return 'unavailable'
}
$hostProcesses = Get-NSHostProcessState
if ($hostProcesses -eq 'Present') {
return 'tabs'
}
if ($hostProcesses -eq 'Unavailable') {
return 'unavailable'
}
return 'dead'
}
$session = Read-NSSession $ns
if ($null -ne $session -and -not [string]::IsNullOrEmpty($session.ProcessId)) {
$processState = Test-NSRecordedProcess $session.ProcessId $session.Start
if ($processState -eq 'Alive') {
if (Test-NSErroredTail) {
return 'wedge'
}
return 'silent'
}
$registry = Get-NSRegistryState
if ($registry -eq 'Present') {
if (Test-NSErroredTail) {
return 'wedge'
}
return 'silent'
}
if ($processState -eq 'Dead' -or $registry -eq 'Absent') {
return 'dead'
}
return 'unavailable'
}
if ($null -ne $session -and $HostName -eq 'claude') {
$registry = Get-NSRegistryState
if ($registry -eq 'Present') {
if (Test-NSErroredTail) {
return 'wedge'
}
return 'silent'
}
if ($registry -eq 'Absent') {
return 'dead'
}
}
if (Test-NSErroredTail) {
return 'wedge'
}
$hostProcesses = Get-NSHostProcessState
if ($hostProcesses -eq 'Present') {
return 'tabs'
}
if ($hostProcesses -eq 'Unavailable') {
return 'unavailable'
}
return 'dead'
}
function Quote-NSWindowsArgument {
param([AllowEmptyString()][string]$Value)
if ($Value -notmatch '[\s"]' -and -not [string]::IsNullOrEmpty($Value)) {
return $Value
}
$builder = New-Object Text.StringBuilder
$null = $builder.Append('"')
$slashes = 0
foreach ($character in $Value.ToCharArray()) {
if ($character -eq '\') {
$slashes++
continue
}
if ($character -eq '"') {
$null = $builder.Append(('\' * (($slashes * 2) + 1)))
$null = $builder.Append('"')
$slashes = 0
continue
}
if ($slashes -gt 0) {
$null = $builder.Append(('\' * $slashes))
$slashes = 0
}
$null = $builder.Append($character)
}
if ($slashes -gt 0) {
$null = $builder.Append(('\' * ($slashes * 2)))
}
$null = $builder.Append('"')
return $builder.ToString()
}
function Quote-NSPowerShellLiteral {
param([AllowEmptyString()][string]$Value)
return "'" + $Value.Replace("'", "''") + "'"
}
function Start-NSAgent {
param(
[Parameter(Mandatory = $true)][int]$Attempt,
[Parameter(Mandatory = $true)][int]$TotalAttempts
)
$sessionId = Get-NSSessionValue 'SessionId'
$hadCursorWorker = $false
if ($HostName -eq 'cursor') {
$hadCursorWorker = -not [string]::IsNullOrEmpty((Get-NSCursorWorkerId))
$workerId = Resolve-NSCursorWorkerId
if ([string]::IsNullOrEmpty($workerId)) {
Write-NSLogLine 'watchman: could not mint a CLI worker - not passing the IDE conversation to agent --resume'
return $false
}
$sessionId = $workerId
}
$null = Confirm-NSWorkTargetLink $workspace
$openBefore = [int](Get-NSBoxCounts $punch).Open
$fresh = $Attempt -ge $TotalAttempts -and $TotalAttempts -gt 1
# The permission scope a revived session starts under is the owner's, and it never widens
# between rungs: a failed revival is retried at the same scope, never a broader one.
$launchScope = Get-NSRecoveryEffectiveScope $workspace $HostName
if ($launchScope -clike 'unavailable:*') {
Write-NSLogLine ('watchman: ' + (Get-NSRecoveryRefusal $launchScope) + '. Not reviving at permissions it cannot show are no broader than the original.')
Write-NSLogLine "watchman: the work is untouched. Resume the shift yourself, or name the scope a revival may use by setting recovery.launchScope to host-default or host-grant in $(Get-NSLayoutName $ns 'rules')."
Write-NSReason -NightshiftDir $ns -Code 'recovery-scope-unavailable'
return $false
}
Write-NSLogLine ('watchman: reviving under launch scope ' + $launchScope)
$prompt = if ($fresh) { $freshPrompt } else { $revivalPrompt }
if ($HostName -eq 'cursor' -and -not $hadCursorWorker) {
$prompt = $freshPrompt
}
$lease = Takeover-NSLease $ns $sessionId $HostName
if ($null -eq $lease) {
Write-NSLogLine 'watchman: process lease transfer failed - not spawning beside an unfenced session'
return $false
}
$commandName = ''
$commandArguments = New-Object Collections.Generic.List[string]
$arguments = New-Object Collections.Generic.List[string]
if (-not [string]::IsNullOrEmpty($Agent)) {
if (Test-Path -LiteralPath $Agent.Trim("'`"") -PathType Leaf) {
$commandName = $Agent.Trim("'`"")
}
else {
$tokens = @($Agent.Trim() -split '\s+' | Where-Object { -not [string]::IsNullOrEmpty($_) })
$commandName = $tokens[0].Trim("'`"")
if ($tokens.Count -gt 1) {
foreach ($token in $tokens[1..($tokens.Count - 1)]) {
$commandArguments.Add($token.Trim("'`""))
}
}
}
$commandArguments.Add($prompt)
}
elseif ($HostName -eq 'cursor') {
$commandName = 'agent'
$commandArguments.Add("--resume=$sessionId")
$commandArguments.Add('-p')
if ($launchScope -ceq 'host-grant') {
$commandArguments.Add('--trust')
$commandArguments.Add('--yolo')
}
$commandArguments.Add('--workspace')
$commandArguments.Add($workspace)
$commandArguments.Add($prompt)
}
elseif ($HostName -eq 'claude') {
$commandName = 'claude'
if ($Attempt -eq 1 -and -not [string]::IsNullOrEmpty($sessionId)) {
$commandArguments.Add('--resume')
$commandArguments.Add($sessionId)
$commandArguments.Add('-p')
}
elseif ($fresh) {
$commandArguments.Add('-p')
}
else {
$commandArguments.Add('--continue')
$commandArguments.Add('-p')
}
if ($launchScope -ceq 'host-grant' -or $launchScope -ceq 'recorded:dangerously-skip-permissions' -or $launchScope -ceq 'recorded:bypass-permissions') {
$commandArguments.Insert(0, '--dangerously-skip-permissions')
}
$commandArguments.Add($prompt)
}
else {
$commandName = 'codex'
$kind = Get-NSCodexIdentityKind $sessionId
if ($Attempt -eq 1 -and $kind -eq 'resumable') {
$commandArguments.Add('exec')
$commandArguments.Add('resume')
if ($launchScope -clike 'recorded:*') {
$commandArguments.Add('-c')
$commandArguments.Add('sandbox_mode="' + $launchScope.Substring('recorded:'.Length) + '"')
}
elseif ($launchScope -ceq 'host-grant') {
$commandArguments.Add('-c')
$commandArguments.Add('sandbox_mode="danger-full-access"')
}
$commandArguments.Add($sessionId)
$commandArguments.Add($prompt)
}
else {
$commandArguments.Add('exec')
if ($launchScope -clike 'recorded:*') {
$commandArguments.Add('-s')
$commandArguments.Add($launchScope.Substring('recorded:'.Length))
}
elseif ($launchScope -ceq 'host-grant') {
$commandArguments.Add('-s')
$commandArguments.Add('danger-full-access')
}
$commandArguments.Add($freshPrompt)
}
}
$invocation = '& { & ' + (Quote-NSPowerShellLiteral $commandName)
foreach ($argument in $commandArguments) {
$invocation += ' ' + (Quote-NSPowerShellLiteral $argument)
}
$invocation += '; $nsOk = $?; $nsExit = $LASTEXITCODE; ' +
'if ($null -ne $nsExit) { exit $nsExit }; if (-not $nsOk) { exit 1 } }'
$fileName = 'powershell.exe'
$arguments.Add('-NoProfile')
$arguments.Add('-NonInteractive')
$arguments.Add('-ExecutionPolicy')
$arguments.Add('Bypass')
$arguments.Add('-Command')
$arguments.Add($invocation)
$oldProject = if ($HostName -eq 'claude') { $env:CLAUDE_PROJECT_DIR } elseif ($HostName -eq 'cursor') { $env:CURSOR_PROJECT_DIR } else { $env:CODEX_PROJECT_DIR }
$oldRevival = $env:NIGHTSHIFT_REVIVAL
$oldGeneration = $env:NIGHTSHIFT_LEASE_GENERATION
$oldNonce = $env:NIGHTSHIFT_LEASE_NONCE
try {
if ($HostName -eq 'claude') {
$env:CLAUDE_PROJECT_DIR = $workspace
}
elseif ($HostName -eq 'cursor') {
$env:CURSOR_PROJECT_DIR = $workspace
}
else {
$env:CODEX_PROJECT_DIR = $workspace
}
$env:NIGHTSHIFT_REVIVAL = '1'
$env:NIGHTSHIFT_LEASE_GENERATION = [string]$lease.Generation
$env:NIGHTSHIFT_LEASE_NONCE = $lease.Nonce
$argumentLine = (($arguments | ForEach-Object { Quote-NSWindowsArgument $_ }) -join ' ')
try {
$process = Start-Process -FilePath $fileName -ArgumentList $argumentLine `
-WorkingDirectory $workTarget -WindowStyle Hidden -PassThru -ErrorAction Stop
}
catch {
Write-NSLogLine ('watchman: spawn failed: ' + $_.Exception.Message)
return $false
}
$start = Get-NSProcessStart $process.Id
$null = Attach-NSLeaseProcess $ns $HostName $lease.Nonce ([string]$lease.Generation) ([string]$process.Id) $start
$process.WaitForExit()
return (Test-NSWatchmanRevivalProved -NightshiftDir $ns -Sentinel '' -IntervalMinutes $IntervalMinutes -OpenBefore $openBefore)
}
finally {
if ($HostName -eq 'claude') {
$env:CLAUDE_PROJECT_DIR = $oldProject
}
elseif ($HostName -eq 'cursor') {
$env:CURSOR_PROJECT_DIR = $oldProject
}
else {
$env:CODEX_PROJECT_DIR = $oldProject
}
$env:NIGHTSHIFT_REVIVAL = $oldRevival
$env:NIGHTSHIFT_LEASE_GENERATION = $oldGeneration
$env:NIGHTSHIFT_LEASE_NONCE = $oldNonce
}
}
function Get-NSHoldReason {
if (Test-Path -LiteralPath (Get-NSLayoutPath $ns 'stop') -PathType Leaf) {
return 'stop-work order'
}
if ((Test-NSRealEnded) `
-or -not (Test-Path -LiteralPath $punch -PathType Leaf)) {
return 'shift ended'
}
if ((Get-NSBoxCounts $punch).Open -eq 0) {
return 'all boxes ticked'
}
if (Test-NSDeadlinePassed) {
return 'deadline passed'
}
if (Test-NSRealSessionEnd) {
return 'clean session end'
}
$verdict = Get-NSSiteVerdict
if ($verdict -eq 'alive') { return 'session activity' }
if ($verdict -eq 'esc') { return 'owner Esc' }
if ($verdict -eq 'silent') { return '' }
if ($verdict -eq 'tabs') { return "a live $HostName process" }
if ($verdict -eq 'unavailable') { return 'process evidence unavailable' }
return ''
}
$workspace = Resolve-NSWorkspaceRoot (Resolve-NSCanonicalPath $Project)
$ns = Join-Path $workspace '.nightshift'
if (-not (Test-Path -LiteralPath $ns -PathType Container)) {
throw "watchman: no .nightshift at $workspace"
}
$stateKind = Get-NSStateKind $workspace
if ($stateKind -in @('malformed', 'future')) {
Write-NSReason $ns 'unsupported-state' $stateKind
throw ('watchman: ' + (Get-NSStateRefuseMessage $stateKind))
}
try {
$workTarget = Resolve-NSWorkTarget $workspace
}
catch {
$workTarget = $workspace
}
if ($IntervalMinutes -lt 0) {
$override = [string]$env:NIGHTSHIFT_WATCH
$rawInterval = Get-NSRule $workspace 'watchMinutes' $override
if ($rawInterval -notmatch '^[0-9]+$') {
Write-NSReason $ns 'unreadable-rules' 'watchMinutes'
throw "watchman: watchMinutes missing or not whole minutes - $(Get-NSLayoutName $ns 'rules') absent or incomplete; run Setup again (/nightshift:setup on Claude Code; ask Nightshift to set up on Codex)"
}
$IntervalMinutes = [int]$rawInterval
}
if ($IntervalMinutes -eq 0) {
exit 0
}
$retrySpacing = Get-NSRule $workspace 'watchRetrySeconds' ([string]$env:NIGHTSHIFT_WATCH_RETRY)
$revivalPrompt = Expand-NSInjectedPaths $workspace (Get-NSRule $workspace 'revivalPrompt' ([string]$env:NIGHTSHIFT_REVIVAL_PROMPT))
$freshPrompt = Expand-NSInjectedPaths $workspace (Get-NSRule $workspace 'freshRevivalPrompt' ([string]$env:NIGHTSHIFT_FRESH_PROMPT))
$notify = Get-NSRule $workspace 'notifyCommand' ([string]$env:NIGHTSHIFT_NOTIFY_CMD)
# Empty watchAgent keeps the host default resume ladder; non-empty is used verbatim.
# --Agent / env wins when already set by the caller.
if ([string]::IsNullOrEmpty($Agent)) {
$Agent = Get-NSRule $workspace 'watchAgent' ([string]$env:NIGHTSHIFT_WATCH_AGENT)
}
$downNotified = $false
if ([string]::IsNullOrEmpty($retrySpacing) -or [string]::IsNullOrEmpty($revivalPrompt) `
-or [string]::IsNullOrEmpty($freshPrompt)) {
$missing = if ([string]::IsNullOrEmpty($retrySpacing)) { 'watchRetrySeconds' }
elseif ([string]::IsNullOrEmpty($revivalPrompt)) { 'revivalPrompt' }
else { 'freshRevivalPrompt' }
Write-NSReason $ns 'unreadable-rules' $missing
throw "watchman: $missing missing - $(Get-NSLayoutName $ns 'rules') absent or incomplete; run Setup again (/nightshift:setup on Claude Code; ask Nightshift to set up on Codex)"
}
$retryValues = New-Object Collections.Generic.List[int]
foreach ($value in ($retrySpacing -split '\s+')) {
if ([string]::IsNullOrEmpty($value)) {
continue
}
if ($value -notmatch '^[0-9]+$') {
throw 'watchman: watchRetrySeconds must contain whole seconds'
}
$retryValues.Add([int]$value)
}
$punch = Get-NSLayoutPath $ns 'punch-list'
$log = Get-NSLayoutPath $ns 'shift-log'
$pidFile = Get-NSLayoutPath $ns 'watchman'
$tick = Get-NSLayoutPath $ns 'watchman-tick'
$watchmanMutex = Enter-NSMutex $ns '.watchman'
if ($null -eq $watchmanMutex) {
throw 'watchman: another watchman owns this workspace'
}
try {
if (Test-NSReparsePoint $pidFile) {
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
}
elseif (Test-Path -LiteralPath $pidFile -PathType Leaf) {
try {
$oldOwner = [IO.File]::ReadAllLines($pidFile)
$oldPid = if ($oldOwner.Count -gt 0) { $oldOwner[0] } else { '' }
$oldStart = if ($oldOwner.Count -gt 1) { $oldOwner[1] } else { '' }
if ($oldPid -ne [string]$PID -and (Test-NSRecordedProcess $oldPid $oldStart) -eq 'Alive') {
throw "watchman: already watching (pid $oldPid)"
}
}
catch {
if ($_.Exception.Message -match 'already watching') {
throw
}
}
}
$null = Write-NSAtomicLines -Path $pidFile -Lines @([string]$PID, (Get-NSProcessStart $PID))
Write-NSLogLine "watchman ($HostName, Windows) armed - every ${IntervalMinutes}m"
$script:WatchStart = Get-NSUnixTime
[IO.File]::WriteAllText($tick, '', $utf8)
Set-NSTranscriptBaseline
$sleepOverrideSeconds = $null
if (-not [string]::IsNullOrEmpty([string]$env:NIGHTSHIFT_WATCH_SLEEP)) {
if ([string]$env:NIGHTSHIFT_WATCH_SLEEP -notmatch '^[0-9]+$') {
throw 'watchman: NIGHTSHIFT_WATCH_SLEEP must be whole seconds'
}
$sleepOverrideSeconds = [int]$env:NIGHTSHIFT_WATCH_SLEEP
}
$wake = 0
$previousStandby = ''
$silentWakes = 0
$armedMarker = Get-NSLayoutPath $ns 'armed'
# Doubles on an exhausted ladder with API evidence, capped at 60m; resets to
# IntervalMinutes on any live pulse or successful revival.
$currentIntervalMinutes = $IntervalMinutes
while ($true) {
$sleepSeconds = if ($null -ne $sleepOverrideSeconds) { $sleepOverrideSeconds } else { $currentIntervalMinutes * 60 }
Start-Sleep -Seconds $sleepSeconds
$wake++
# Before anything else: a disarmed site or a replaced watchman stands down without
# touching any other state.
if (-not (Test-Path -LiteralPath $armedMarker -PathType Leaf)) {
Write-NSReason $ns 'owner-disarm'
Write-NSLogLine "watchman: the armed marker is gone $([char]0x2014) standing down"
exit 0
}
if (-not (Test-Path -LiteralPath $pidFile -PathType Leaf)) {
Write-NSLogLine "watchman: the watchman pidfile is gone $([char]0x2014) standing down"
exit 0
}
if (Test-Path -LiteralPath (Get-NSLayoutPath $ns 'stop') -PathType Leaf) {
Write-NSReason $ns 'owner-stop'
Write-NSLogLine 'watchman: stop-work order - standing down'
exit 0
}
if (Test-NSRealEnded) {
Write-NSReason $ns 'completed'
exit 0
}
if (-not (Test-Path -LiteralPath $punch -PathType Leaf)) {
Write-NSReason $ns 'stand-down' 'punch list missing'
exit 0
}
$session = Read-NSSession $ns
$sessionHost = if ($null -eq $session) { 'claude' } else { $session.HostName }
if ($sessionHost -ne $HostName) {
Write-NSReason $ns 'wrong-host' $sessionHost
Write-NSLogLine "watchman: shift belongs to $sessionHost - standing down"
exit 0
}
$counts = Get-NSBoxCounts $punch
if ($counts.Open -eq 0 -or (Test-NSDeadlinePassed)) {
$label = if ($counts.Open -eq 0) { 'every box is ticked' } else { 'quitting time passed' }
Write-NSLogLine "watchman: $label but the shift never clocked out - spawning the clock-out (attempt 1/1)"
$null = Start-NSAgent 1 2
if (Test-NSRealEnded) {
$null = Release-NSLease $ns
Write-NSReason $ns $(if ($counts.Open -eq 0) { 'completed' } else { 'deadline' })
exit 0
}
$null = Restore-NSLeaseInteractive $ns
Write-NSReason $ns 'clock-out-failed'
Write-NSLogLine 'watchman: clock-out attempt 1/1 returned without releasing the shift - standing down'
exit 0
}
if (Test-NSRealSessionEnd) {
Write-NSReason $ns 'clean-session-end'
Write-NSLogLine 'watchman: clean session end - the owner closed it; standing down'
exit 0
}
$verdict = Get-NSSiteVerdict
if ($verdict -eq 'wedge' -and $HostName -eq 'claude' -and (Test-NSUsageLimitedTail) -and (Test-NSUsageLimitRevivalOff)) {
Write-NSReason $ns 'usage-limit' 'revival after a usage limit is off'
if ($previousStandby -ne 'usage-limit') {
$null = Write-NSUsagePause $ns 'usage limit'
Write-NSLogLine 'watchman: the session stopped on a usage limit and revival after a usage limit is off (watchAfterUsageLimit) - standing by for the owner'
}
$verdict = 'usage-limit'
}
if ($verdict -eq 'silent') {
$silentWakes++
if ($silentWakes -ge 2 -and (Test-NSPulseStale $ns $IntervalMinutes)) {
Write-NSLogLine 'watchman: silent too long with a stale pulse - treating as dead'
$verdict = 'dead'
$silentWakes = 0
}
}
else {
$silentWakes = 0
}
if ($verdict -eq 'alive') {
$previousStandby = ''
$currentIntervalMinutes = $IntervalMinutes
}
elseif ($verdict -eq 'esc') {
Write-NSReason $ns 'esc-standby'
if ($previousStandby -ne 'esc') {
$null = Write-NSUsagePause $ns 'owner pressed Esc'
Write-NSLogLine 'watchman: owner pressed Esc - standing by, not resuming'
}
$previousStandby = 'esc'
}
elseif ($verdict -eq 'usage-limit') {
$previousStandby = 'usage-limit'
}
elseif ($verdict -eq 'silent' -or $verdict -eq 'tabs') {
Write-NSReason $ns 'silent-standby' $verdict
if ($previousStandby -ne $verdict) {
Write-NSLogLine "watchman: live $HostName process evidence - standing by"
}
$previousStandby = $verdict
}
elseif ($verdict -eq 'unavailable') {
Write-NSReason $ns 'process-evidence-unavailable'
if ($previousStandby -ne 'unavailable') {
Write-NSLogLine 'watchman: process evidence unavailable - standing down, not reviving'
}
$previousStandby = 'unavailable'
}
else {
$previousStandby = ''
if ($HostName -eq 'cursor' -and [string]::IsNullOrEmpty((Get-NSCursorWorkerId)) -and (Test-NSMintFailed)) {
Write-NSReason $ns 'silent-standby' 'mint-failed'
Set-NSTranscriptBaseline
[IO.File]::WriteAllText($tick, '', $utf8)
if ($MaxWakes -gt 0 -and $wake -ge $MaxWakes) {
exit 7
}
continue
}
$sessionId = Get-NSSessionValue 'SessionId'
if ($HostName -eq 'codex' -and [string]::IsNullOrEmpty($Agent)) {
$kind = Get-NSCodexIdentityKind $sessionId
if ($kind -notin @('resumable', 'missing')) {
Write-NSReason $ns 'non-resumable-session' $kind
Write-NSLogLine "watchman: recorded Codex identity is $kind - standing down"
exit 0
}
}
if ($verdict -eq 'wedge') {
Write-NSLogLine 'watchman: probable API wedge - reviving the recorded conversation'
}
$totalAttempts = $retryValues.Count + 1
$revived = $false
$aborted = ''
for ($attempt = 1; $attempt -le $totalAttempts; $attempt++) {
if ($attempt -gt 1) {
$gap = $retryValues[$attempt - 2]
if ($gap -gt 0) {
Start-Sleep -Seconds $gap
}
}
# The marker is the shift: gone mid-ladder means nothing is left to revive.
if (-not (Test-Path -LiteralPath $armedMarker -PathType Leaf)) {
Write-NSReason $ns 'owner-disarm'
Write-NSLogLine "watchman: the armed marker is gone $([char]0x2014) standing down"
exit 0
}
if ($attempt -gt 1) {
$aborted = Get-NSHoldReason
if (-not [string]::IsNullOrEmpty($aborted)) {
Write-NSLogLine "watchman: $aborted during retries - holding the remaining attempts"
break
}
}
Write-NSLogLine "watchman: site dead quiet with open boxes - resume attempt $attempt"
if (Start-NSAgent $attempt $totalAttempts) {
$revived = $true
break
}
Set-NSTranscriptBaseline
}
if ($revived) {
$currentIntervalMinutes = $IntervalMinutes
if ($totalAttempts -gt 1 -and $attempt -ge $totalAttempts) {
Write-NSReason $ns 'fresh-fallback'
}
elseif ([string]::IsNullOrEmpty($sessionId)) {
Write-NSReason $ns 'fresh-fallback'
}
else {
Write-NSReason $ns 'revived'
}
$downNotified = $false
$stamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$cursorWorker = Get-NSCursorWorkerId
$notice = if (-not [string]::IsNullOrEmpty($sessionId) -and $HostName -eq 'claude') {
"- [notice] $stamp - the shift session died and the watchman revived it. One thread: claude --resume $sessionId $([char]0x00B7) cursor://anthropic.claude-code/open?session=$sessionId $([char]0x00B7) vscode://anthropic.claude-code/open?session=$sessionId"
}
elseif ($HostName -eq 'cursor' -and -not [string]::IsNullOrEmpty($cursorWorker)) {
"- [notice] $stamp - the shift session died and the watchman revived it in a CLI worker. To see it, run this in a terminal: agent --resume=`"$cursorWorker`" --workspace `"$workspace`". To stop it, ask Nightshift to stop."
}
else {
"- [notice] $stamp - the shift session died and the watchman revived it (details in shift-log.md)."
}
[IO.File]::AppendAllText((Get-NSLayoutPath $ns 'parking-lot'), $notice + [Environment]::NewLine, $utf8)
if (-not [string]::IsNullOrEmpty($sessionId) -and $HostName -eq 'claude') {
Write-NSLogLine "watchman: revival returned - the night is one thread: claude --resume $sessionId"
}
else {
Write-NSLogLine 'watchman: revival returned - the night continues'
}
}
elseif ([string]::IsNullOrEmpty($aborted)) {
Write-NSReason $ns 'exhausted-retry'
if (Test-NSApiFailureEvidence) {
$currentIntervalMinutes = [Math]::Min($currentIntervalMinutes * 2, 60)
$null = Write-NSUsagePause $ns 'usage limit'
Write-NSLogLine "watchman: all $totalAttempts attempts failed (api down?) $([char]0x2014) backing off, knocking again in ${currentIntervalMinutes}m"
}
else {
Write-NSLogLine "watchman: all $totalAttempts attempts failed - retrying next wake"
}
# A failed ladder must not leave the origin fenced: hand the lease back so the
# next hook call in the recorded conversation is allowed without waiting on the
# hardhat backstop.
$null = Restore-NSLeaseInteractive $ns
if (-not [string]::IsNullOrEmpty($notify) -and -not $downNotified) {
$downNotified = $true
$summary = 'nightshift: the shift session is down and revival failed - it needs you'
if (-not [string]::IsNullOrEmpty($sessionId) -and $HostName -eq 'claude') {
$summary = "$summary : claude --resume $sessionId"
}
$oldSummary = $env:NIGHTSHIFT_SUMMARY
try {
$env:NIGHTSHIFT_SUMMARY = $summary
$null = & (Get-Process -Id $PID).Path -NoProfile -NonInteractive -Command $notify 2>$null
}
catch {
}
finally {
$env:NIGHTSHIFT_SUMMARY = $oldSummary
}
}
}
}
Set-NSTranscriptBaseline
[IO.File]::WriteAllText($tick, '', $utf8)
if ($MaxWakes -gt 0 -and $wake -ge $MaxWakes) {
exit 7
}
}
}
finally {
try {
if (Test-Path -LiteralPath $pidFile -PathType Leaf) {
$ownerPid = ([IO.File]::ReadAllLines($pidFile) | Select-Object -First 1)
if ($ownerPid -eq [string]$PID) {
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
}
}
}
finally {
Exit-NSMutex $watchmanMutex
}
}
SHA-256: 92bdc005065b9b43461476ae531353865e4d2a50ecec7b761f41ecc94f8c25e1